Tutorials

ECONNREFUSED: Meaning, the localhost Trap, and Fixes

By Published 11 min read
ECONNREFUSED: Meaning, the localhost Trap, and Fixes

TL;DR

ECONNREFUSED means a reachable machine answered no. Read the address and port fields, rule out the localhost dual-stack trap, then fix the listener.

On this page

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.

ECONNREFUSED and the codes it gets confused with
CodeOn the wireWhat it proves
ECONNREFUSEDAnswered with a resetReachable address, nothing serves that port
ETIMEDOUTNo answer before the deadlineDropped packets or an unreachable host: the timeout guide
ENOTFOUNDDNS returned no addressA wrong hostname or resolver: the name resolution guide
ECONNRESETAn open connection was torn downSomething 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.

EF-04 / Failure pointWhere ECONNREFUSED happens in the request path
ECONNREFUSEDerrno -4078

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 8124

Read 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  // 8124

Log 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:LISTEN

Read 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 refused

Each 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.

Default ports and why a connection to them is refused
ServicePortUsual cause of the refusal
MySQL, MariaDB3306bind-address pinned to loopback, or a Unix client given localhost using a socket file instead of TCP
PostgreSQL5432listen_addresses left at localhost, or an upgrade leaving a second cluster on 5433
MongoDB27017bindIp still at the shipped loopback default, or mongod is not running
Redis6379a bind directive pinned to loopback
Node, Next, Vite3000, 5173the port was busy at startup, so the process moved and the URL did not
Django, Flask8000, 5000the 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.

Frequently Asked Questions

What does ECONNREFUSED mean?
A machine at the address you dialed answered your connection attempt with an explicit refusal instead of accepting it. Nothing is listening on that port, or a rule rejects connections to it. The name resolved and the host is reachable, so the problem is narrower than it looks: it lives at the port, not along the path.
Is ECONNREFUSED the same as a timeout?
No, and this is the most common misreading. A timeout is silence, reported as ETIMEDOUT after your client waits out its full deadline. ECONNREFUSED is an immediate answer. That is why raising timeouts or enlarging a connection pool never helps: the refusal repeats identically until the listener or the rule behind it changes.
Why do I get ECONNREFUSED on localhost when my server is running?
Almost always because the name and the bind address disagree. localhost maps to both ::1 and 127.0.0.1, and a server bound to one refuses connections to the other. The recorded lab reproduced that in both directions on Node v24.14.0. Dial 127.0.0.1 explicitly, or bind the address you actually connect to.
What is errno -4078?
It is the platform specific number that accompanied ECONNREFUSED in the recorded lab run on Windows. The identical refusal carries a different number on other operating systems, which is why errno is the wrong field to branch on. Use error.code, then read address and port for the detail that solves the case.
Why does Postman get ECONNREFUSED when my browser loads the same URL?
The two are not making the same connection. Desktop API clients have their own proxy settings and can also inherit the system proxy or the HTTP_PROXY variables, so the tool dials a proxy rather than your service. Turn the tool's proxy off and retry. Browser based clients are a separate case: they need a local agent before 127.0.0.1 is reachable.
How do I fix ECONNREFUSED connecting to MongoDB, MySQL, or PostgreSQL?
Check that the service is running, then what address it bound, then the port: 27017, 3306 and 5432 respectively. All three ship bound to loopback, so connections from a container or another machine are refused by design. Watch for a Postgres upgrade leaving a second cluster on 5433.
Why does my Docker container get ECONNREFUSED to localhost?
Because inside a container, localhost is that container, not your host and not another container. Use the container or service name for traffic between containers, and host.docker.internal to reach the host on Docker Desktop. Then confirm the port is published rather than merely exposed, and that the service inside listens on 0.0.0.0.
Should my code retry on ECONNREFUSED?
Only when you know the target is still starting up. A refusal is a final answer, so a tight retry loop repeats it thousands of times a second while burning CPU and filling logs. For startup ordering use a bounded wait: a few attempts, exponential backoff, a ceiling, then fail with the address and port in the message.

Related reading

Ready to scale your data collection?

Join 8,000+ customers on Databay: 34M+ residential IPs across 200+ countries, pay as you go.

Pricing, order minimums, and traffic validity vary by product.