It’s 9:40 on a Tuesday, you just docker compose up‘d the stack, and the API container dies on boot with a stack trace pointing at your Postgres pool. The database container is right there, green, healthy. And yet Node insists nothing is listening. connect ECONNREFUSED is the most-misread error in the Node ecosystem, because nearly everyone treats it as “the network is flaky” when it almost always means “you dialed the wrong number.” When you see connect ECONNREFUSED, the OS is telling you it reached a host and that host actively said no — there is nothing accepting connections on that exact IP and port.
Quick diagnosis
ECONNREFUSED means Node reached the target host and port, but nothing accepted the TCP connection. Read the host and port in the error, confirm something is listening there, check the IPv4/IPv6 localhost mismatch, and remember that localhost inside Docker points to the container, not your laptop or database service.
Here’s the actual error, the one you pasted into a search box to land here:
Error: connect ECONNREFUSED 127.0.0.1:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1607:16) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 5432
}
ECONNREFUSED is not a timeout. A timeout (ETIMEDOUT) means your packets vanished into the void — wrong host, dropped by a firewall, no route. ECONNREFUSED means a machine answered and sent back a TCP RST: “I am here, but port 5432 is closed.” That distinction is the whole article. The OS already did the hard diagnostic work for you and printed the result on two lines: address and port. Read them.
The copy-paste triage, before anything else:
# Is anything actually listening on that port? (the number from the error)
lsof -iTCP:5432 -sTCP:LISTEN -n -P
# or, distro-agnostic:
nc -vz 127.0.0.1 5432
If nc says Connection refused, your app is right and the dependency genuinely isn’t there. If nc succeeds but Node still fails, you have a host-resolution problem — and that’s the IPv6 trap below, which bites more people than every other cause combined.
The tempting shortcut that wastes time
You bump the connection timeout to 30 seconds. Still fails. You wrap the connect in a retry loop with five attempts. Still fails, just slower and with more log noise. Then you add exponential backoff and go make coffee while it retries into a wall for two minutes.
None of that can work, because retries and timeouts only help when the dependency is coming up late. ECONNREFUSED on a port that will never open just means you’re knocking on a door that doesn’t exist — knocking harder and longer changes nothing. I’ve watched engineers add a 90-second startup grace period to a service whose only problem was a 5433 typo in the port. The retry loop dutifully failed every 5 seconds for 90 seconds, then crashed, having proven nothing except that 5433 was still wrong.
Retries are a real tool and we’ll get to them — they belong in exactly one scenario (a dependency that’s genuinely still booting). But they are step nine, not step one. Step one is reading the address:port in the error and confirming it’s the number you meant. If you skip that, you’re debugging the wrong layer.
Fix the localhost IPv6 trap in Node.js
You connect to localhost:5432. Postgres is running, bound to 127.0.0.1, nc -vz 127.0.0.1 5432 succeeds — and Node still throws connect ECONNREFUSED ::1:5432. Look at that address again: ::1, not 127.0.0.1. That’s the IPv6 loopback.
Since Node 17, dns.lookup() defaults to verbatim ordering, which means it returns whatever order the resolver hands back — and on most modern systems, localhost resolves to ::1 first. Node tries IPv6, your Postgres only listens on IPv4, and you get a refusal. This default carried through Node 18, 20, and is still verbatim in Node 22 and 24. It is the single most common reason localhost works in your shell (where psql falls back to a unix socket) but explodes in your app.
The cleanest fix is to stop relying on a name that resolves to two addresses. Use the literal IPv4:
import { Pool } from 'pg'; // pg@8.21.0
const pool = new Pool({
host: '127.0.0.1', // not 'localhost' — dodges the ::1 lottery
port: 5432,
database: 'app',
});
If you can’t touch every connection string (third-party libs, env-driven config), flip the resolution order process-wide. One line, before any connection is opened:
import { setDefaultResultOrder } from 'node:dns';
setDefaultResultOrder('ipv4first'); // IPv4 addresses sorted first
Or do it at launch without code changes:
node --dns-result-order=ipv4first server.js
There’s a third option people forget exists: Happy Eyeballs. Node 20+ ships autoSelectFamily, which races IPv4 and IPv6 in parallel (RFC 8305) and keeps whichever connects first — so a dead ::1 costs you milliseconds instead of a crash. It’s off by default in Node 22, which surprises people who assumed the runtime got smarter. Turn it on globally:
import { setDefaultAutoSelectFamily } from 'node:net';
setDefaultAutoSelectFamily(true);
The full mechanics live in the Node DNS docs. My rule of thumb: hard-code 127.0.0.1 in app config you own, and reach for autoSelectFamily only when some dependency forces a hostname on you.
The dependency genuinely isn’t running
Sometimes the boring answer is the right one: nothing is listening because you never started it. nc -vz refused, lsof shows no listener, and there’s no trickery to debug.
Confirm the service is up and bound where you expect:
# Postgres
pg_isready -h 127.0.0.1 -p 5432 # "accepting connections" or "no response"
# Redis
redis-cli -h 127.0.0.1 -p 6379 ping # expect: PONG
# A plain HTTP upstream
curl -sS -o /dev/null -w '%{http_code}n' http://127.0.0.1:3000/health
The trap here is a service that is running but bound to the wrong interface. Postgres defaulting to listen_addresses = 'localhost', or a container app bound to 127.0.0.1 instead of 0.0.0.0, will refuse every connection from outside its own loopback even though ps shows it alive. If your app and your database disagree about which interface counts as “local,” you’ll get ECONNREFUSED with both sides looking perfectly healthy. While you’re confirming ports, this is also a good moment to check you haven’t accidentally collided two services on one port — that throws EADDRINUSE on the server side, the mirror image of this error on the client side.
Docker: localhost inside a container is not your host
This is where most ECONNREFUSED tickets actually come from. Inside a container, 127.0.0.1 means that container, not your laptop and not the database container. So host: 'localhost' in a Dockerized API points the connection at the API container’s own empty loopback — which refuses, every time.
In Docker Compose, services reach each other by service name, on the network Compose creates for you:
const pool = new Pool({
host: process.env.PGHOST ?? 'db', // the compose service name, NOT localhost
port: 5432,
database: 'app',
});
services:
db:
image: postgres:17
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
api:
build: .
environment:
PGHOST: db # resolves to the db container on the compose network
depends_on:
- db
If your stack runs Postgres on the host and the app in a container, the bridge is host.docker.internal (works on Docker Desktop; on Linux you add extra_hosts: ["host.docker.internal:host-gateway"]). I lay out the full networking model — bridge networks, published vs internal ports, the host.docker.internal escape hatch — in the Node Docker containerization guide. For the official version, Docker’s networking docs spell out the service-name DNS behavior.
Connecting before the dependency is ready
Now — only now — we get to the case retries were built for. depends_on alone is a liar: Compose marks a dependency satisfied as soon as the container is running, not when Postgres has finished its init scripts and is accepting connections. So your API starts, dials db:5432, and gets refused because Postgres is still 1.5 seconds from opening the socket.
Two things fix this together. First, a real healthcheck so Compose waits for ready, not merely started:
services:
db:
image: postgres:17
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
api:
build: .
environment:
PGHOST: db
depends_on:
db:
condition: service_healthy # wait for the healthcheck, not just "running"
Note the doubled $$ — that’s how you escape $ so Compose passes the variable through to the shell instead of interpolating it itself. Get that wrong and pg_isready runs with empty values and the check silently never passes.
Second, your app should still retry on its own, because orchestrators restart things and networks blip. This is where backoff earns its keep — bounded, with a hard ceiling, and only because you’ve already confirmed the host and port are correct:
import { Pool } from 'pg';
const pool = new Pool({ host: process.env.PGHOST ?? '127.0.0.1', port: 5432, database: 'app' });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitForDb(maxAttempts = 8): Promise<void> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await pool.query('SELECT 1');
return; // connected
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ECONNREFUSED') throw err; // wrong creds/db? don't retry, surface it
const delay = Math.min(1000 * 2 ** (attempt - 1), 15_000); // 1s,2s,4s… cap 15s
console.warn(`db not ready (attempt ${attempt}/${maxAttempts}), retry in ${delay}ms`);
await sleep(delay);
}
}
throw new Error('database unreachable after backoff');
}
await waitForDb();
The same service_healthy pattern works for Redis (swap the test for redis-cli ping) — and the backoff logic is identical whether you’re on ioredis@5.11.1 or building a cache layer like the one in Node Redis caching. One thing that loop does deliberately: it rethrows immediately on anything that isn’t ECONNREFUSED. Retrying a wrong password eight times just delays a failure you should see on attempt one.
FAQ
What does connect ECONNREFUSED mean in Node.js?
It means the TCP connection reached a host and that host actively refused it — there is no process listening on the exact IP and port shown in the error. It is not a timeout (ETIMEDOUT); a timeout means the packets never got an answer at all, while ECONNREFUSED is an explicit “nothing’s home on this port.”
Why do I get ECONNREFUSED ::1 when my server is on 127.0.0.1?
Since Node 17 (still true in Node 22 and 24), localhost resolves with verbatim DNS ordering and often returns the IPv6 loopback ::1 first, but your service only listens on IPv4 127.0.0.1. Use the literal 127.0.0.1 in your connection string, or set dns.setDefaultResultOrder('ipv4first') before connecting.
How do I fix ECONNREFUSED between Docker containers?
Inside a container 127.0.0.1 refers to that container itself, not the database. In Docker Compose, connect using the dependency’s service name (e.g. db:5432) instead of localhost, since Compose puts services on a shared network with name-based DNS.
Does increasing the connection timeout fix ECONNREFUSED?
No. A longer timeout or more retries only helps when the dependency is still starting up. If the port is wrong or nothing will ever listen there, the connection is refused instantly every time and waiting changes nothing — verify the host:port first.
How do I make Node wait for Postgres before connecting?
Add a Compose healthcheck using pg_isready and depend on it with condition: service_healthy so the app starts only after Postgres accepts connections, then add a bounded retry-with-backoff in your app for restarts and network blips. Combining the two covers both the cold-start race and transient failures.
Should I retry on every ECONNREFUSED?
Only retry when you’ve confirmed the host and port are correct and the dependency may simply be booting. Retry on ECONNREFUSED, but rethrow immediately on errors like bad credentials or a missing database — those won’t fix themselves no matter how many attempts you make.
