What ECONNREFUSED Actually Means#
ECONNREFUSED is the code your operating system hands your program when a TCP connection attempt is answered with an explicit refusal. Your client dialed an address and a port, a machine there found no socket listening, and replied with a reset. RFC 9293 states the rule: if the connection does not exist, a reset is sent in response to any incoming segment except another reset.
The load-bearing word is answered. The name resolved, a route existed, and the host's stack replied, so everything up to the port worked. Either nothing is listening at that address and port, or a rule rejects connections to it. Your request was never seen: the kernel answered before any server process was involved, so there is no status code and no access log line to hunt for.
The reply also arrives at the speed of a round trip, not at the end of your connect deadline. Upvoted forum answers still claim this error means your request is timing out. It means the opposite: a timeout is silence, which is why raising timeouts and adding retries are wasted here.
| Code | On the wire | What it proves |
|---|---|---|
ECONNREFUSED | Answered with a reset | Reachable address, nothing serves that port |
ETIMEDOUT | No answer before the deadline | Dropped packets or an unreachable host: the timeout guide |
ENOTFOUND | DNS returned no address | A wrong hostname or resolver: the name resolution guide |
ECONNRESET | An open connection was torn down | Something objected after it existed: the reset guide |
Browsers label the same event differently, so ERR_CONNECTION_REFUSED in Chrome and ECONNREFUSED in your terminal are one diagnosis.
Client
browser or CLI
DNS
name → address
TCP
RST instead of SYN-ACK
TLS
handshake + cert
Origin
HTTP status + body
Hops after the highlighted failure never run — everything downstream is ruled out before you start.
The refusal is an answer, not silence: something at that address actively rejected the TCP connection, so DNS is already ruled in and TLS never ran.
Read the Error Object Before You Change Anything#
Node attaches the operating system's own details to the thrown object, and the system error reference names the fields: code, errno, syscall, address and port. The recorded lab captured them on Node v24.14.0 against a loopback port with nothing listening, and this one-liner reproduces them:
node -e "require('node:net').connect(8124, '127.0.0.1').on('error', e => console.log(e.code, e.errno, e.syscall, e.address, e.port))"
ECONNREFUSED -4078 connect 127.0.0.1 8124Read address and port first. They report where your process actually dialed rather than where you believe it dialed, which ends most investigations on the spot. An address of ::1 against a server that bound 127.0.0.1 is the dual-stack section below. A port of 3000 against a dev server that moved to 3001 is already solved.
syscall confirms the socket broke while opening rather than during a later read or write. errno is the field to ignore: it is the platform's own number, recorded as -4078 on the Windows machine that ran this lab and different on other systems. Branch on code, never on errno or the wording of message.
Wrappers push all of it one level down. fetch rejects with a TypeError whose message is only "fetch failed", and the lab confirmed the identical code sits in cause:
const error = await fetch('http://127.0.0.1:8124/').catch((e) => e);
error.message // 'fetch failed'
error.cause.code // 'ECONNREFUSED'
error.cause.port // 8124Log the object, not the message.
Ask the Operating System Who Is Listening#
Two observations split every remaining cause. First, ask the operating system what is bound, instead of trusting a terminal you read five minutes ago.
# Windows
netstat -ano | findstr :3000
# Linux
ss -ltnp | grep :3000
# macOS
lsof -nP -iTCP:3000 -sTCP:LISTENRead the address column, not just the port. 127.0.0.1:3000 is the IPv4 loopback only, [::1]:3000 is the IPv6 loopback only, and rows for 0.0.0.0:3000 and [::]:3000 together mean every interface.
Second, probe that exact address with curl in verbose mode: it prints the address it dialed, and exits with status 7.
curl -v http://127.0.0.1:8124/
* Trying 127.0.0.1:8124...
* connect to 127.0.0.1 port 8124 from 0.0.0.0 port 63101 failed: Connection refusedEach pairing rules something in. Nothing bound to the port means the service is not running or died at startup, so read its logs. A listener on another port means your client is wrong, or the service moved because its preferred port was taken. A listener whose address differs from the one curl printed is the bind trap below. A listener that matches while connections still fail means something sits in between: a proxy, a container boundary, or a firewall rule. That last case has a tell, because a rule that rejects sends the reset you are reading, while a rule that drops leaves you waiting on a timeout.
The localhost Dual-Stack Trap#
Two things are true at once here: the server is unambiguously running, and the connection is unambiguously refused.
A listening socket owns one address and one port, not a port by itself. localhost is a name that maps to two addresses on any machine with IPv6 enabled, ::1 and 127.0.0.1. Your client picks one, your server picked one when it bound, and when the picks disagree the kernel sees a port with no listener.
The lab measured that in both directions on Node v24.14.0, five checks of five passing. A server bound to 127.0.0.1 accepted a connection at 127.0.0.1. That same server, still running and still bound, refused a connection to ::1 with ECONNREFUSED. A server bound to ::1 then refused 127.0.0.1, again with ECONNREFUSED. The trap runs both ways, and neither refusal hints that a healthy listener sits one address over.
So read the error as "nothing is listening at the address I asked for", not "the service is down". It also explains the intermittency: Node's net module has enabled address family autodetection by default since v20, the autoSelectFamily option, so a bare fetch to localhost often recovers while a Postgres driver handed an explicit literal cannot.
Two fixes cover nearly all of it. In development, dial 127.0.0.1 instead of the name, in every config file and connection string. Or bind the address you dial: passing a host to listen binds that address alone, while omitting it binds the unspecified address, which the same reference notes may also accept IPv4 on most systems.
The Ports People Get Wrong#
When ECONNREFUSED names a well-known service port, there are only three answers: nothing is listening there, the service is listening somewhere else, or the port moved.
| Service | Port | Usual cause of the refusal |
|---|---|---|
| MySQL, MariaDB | 3306 | bind-address pinned to loopback, or a Unix client given localhost using a socket file instead of TCP |
| PostgreSQL | 5432 | listen_addresses left at localhost, or an upgrade leaving a second cluster on 5433 |
| MongoDB | 27017 | bindIp still at the shipped loopback default, or mongod is not running |
| Redis | 6379 | a bind directive pinned to loopback |
| Node, Next, Vite | 3000, 5173 | the port was busy at startup, so the process moved and the URL did not |
| Django, Flask | 8000, 5000 | the development server binds 127.0.0.1 only |
Databases ship bound to loopback on purpose, so the first connection from another machine or container is refused by design. The fix is a deliberate bind change on the server plus a matching firewall rule, never a client-side tweak.
A port you never chose can also appear, because a URL without an explicit port falls back to its scheme's default: https://localhost/api dials 443, where your dev server on 3000 is not listening.
Transfer clients hit the same wall through protocol confusion instead. FTP is 21, implicit FTPS is 990, and SFTP is a subsystem of SSH on 22, so a host offering only SFTP refuses 21 immediately.
Containers: Inside a Container, localhost Is the Container#
Containers multiply the addresses in play, and they are the largest single source of ECONNREFUSED in modern stacks. Four mistakes account for almost all of it.
First and most common: inside a container, localhost is that container. A process dialing localhost:5432 asks its own loopback for Postgres, and the database is a different container with its own network namespace. Containers on a user-defined network reach each other by container name, as Docker's networking documentation describes, so the string is postgres:5432. The host is a separate case with its own name, host.docker.internal on Docker Desktop.
Second, a port that is not published is not reachable from the host. EXPOSE documents intent and publishes nothing, while -p 5432:5432, or a ports entry in Compose, creates the mapping. In docker ps, a real mapping shows both sides; a bare 5432/tcp means nothing was published.
Third, and this catches people who did the first two correctly: a service that binds 127.0.0.1 inside the container is refused even when the port is published, because published traffic arrives on the container's network interface, not its loopback. Listen on 0.0.0.0 instead.
Fourth, a container that is up is not a service that is ready. Compose's depends_on waits for the container to start, not for the process inside to accept connections, so an app that connects during boot is refused for the first seconds.
When Postman Refuses and the Browser Works#
A URL that loads in your browser and fails in Postman or a database GUI is not producing the same connection, and the usual difference is a proxy.
Desktop API clients carry their own proxy settings and can also inherit the system proxy, and many command line tools and SDKs read HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment. With any set, the tool dials the proxy instead of your service, so the refusal is the proxy's address, or a proxy declining a loopback destination it has no route to. Your error object settles it: if address is not the host you typed, you are looking at a proxy hop.
The diagnosis is one step: turn the tool's proxy off and retry. If it is already off, check the operating system's proxy settings and those variables, then exempt localhost and 127.0.0.1 so development traffic stops leaving the machine. The proxy and firewall checklist walks those settings, and a proxy that answers but fails to open the tunnel is a different failure. A VPN or a security suite changes the route too, so switching one off briefly is a legitimate way to test it.
If the refusal comes from a managed network's gateway, that is a policy your organization set: diagnose it, then take the evidence to whoever runs the network. Reconfiguring a client to route around a rule that refused you on purpose is not a fix.
Handling ECONNREFUSED in Code#
Branch on the code, not the message. error.code === 'ECONNREFUSED' is stable across platforms and runtime versions, while message strings and errno values are not, and under fetch the code lives at error.cause.code.
Then decide what a refusal means for that dependency, because there are only two honest answers. If the service should already be running, fail immediately and loudly, with the address and port in the error you raise: cannot reach postgres at 127.0.0.1:5432 ends the investigation, while "database unavailable" costs the next person an hour. If it is expected to be starting, wait within a bound: a few attempts, backoff, a ceiling, then a real failure a supervisor can act on. Never retry in a tight loop, because a refusal is a complete answer that arrives at once.
const probe = (host, port) =>
new Promise((resolve, reject) => {
const socket = net.connect({ host, port });
socket.on('connect', () => { socket.end(); resolve(); });
socket.on('error', reject);
});
export async function waitForListener(host, port, attempts = 8) {
for (let i = 0; i < attempts; i += 1) {
try { return await probe(host, port); }
catch (error) {
if (error.code !== 'ECONNREFUSED') throw error;
await new Promise((go) => setTimeout(go, Math.min(200 * 2 ** i, 5000)));
}
}
throw new Error(`no listener on ${host}:${port} after ${attempts} attempts`);
}A port that accepts a connection is still not a service ready to work, so where it matters probe what the dependency needs: a query, or a health endpoint your readiness check calls.



