Your app starts, tries to reach a database or another service, and fails with this (Node v22.23.2, nothing listening on port 5432):
Error: connect ECONNREFUSED 127.0.0.1:5432
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1638:16) {
errno: -61,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 5432
}If your configuration says localhost rather than 127.0.0.1, Node 20 and later try both IPv6 and IPv4 and report both failures together. Note the empty message after the colon:
AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1135:18)
at afterConnectMultiple (node:net:1716:7) {
code: 'ECONNREFUSED',
[errors]: [
Error: connect ECONNREFUSED ::1:5432 ...
Error: connect ECONNREFUSED 127.0.0.1:5432 ...
]
}With fetch, the same failure is wrapped as TypeError: fetch failed, and the ECONNREFUSED detail is on err.cause. In every form the meaning is the same: the TCP connection reached a machine, and that machine immediately answered “no process is accepting connections on this port”.
Quick fix checklist
- Is the service running?
lsof -nP -iTCP:5432 -sTCP:LISTEN(macOS/Linux),ss -ltn 'sport = :5432'(Linux), orTest-NetConnection 127.0.0.1 -Port 5432(PowerShell). - Is the port right? Print the host and port your code actually uses at startup; a missing environment variable often falls back to a default.
- Does the server listen on the address you connect to? A server on
127.0.0.1refuses::1, and vice versa. - Running in Docker?
localhostinside a container means that container. Use the Compose service name (db), orhost.docker.internalfor a service on your machine. - Failing only right after
docker compose up? The dependency is still starting; add a health check and a bounded retry.
Before you start
You need the error text (especially the address and port it names), access to the machine or container where the client runs, and some way to check listening sockets there. Examples use Node 22 or 24; the IPv6 behaviour described below depends on the Node version, so run node -v first.
Why it happens
A TCP client starts a connection by sending a SYN packet. If a process is listening on that address and port, the kernel completes the handshake. If nothing is listening, the kernel answers with a reset (RST) and the client’s connect fails with ECONNREFUSED. A firewall configured to reject (rather than drop) packets produces the same answer.
That speed is a clue. ECONNREFUSED comes back in milliseconds, which proves the host is reachable and the port is closed. A firewall that silently drops packets, or a wrong IP that nobody owns, produces ETIMEDOUT after a long wait instead. A bad hostname produces ENOTFOUND or EAI_AGAIN, which fail before any connection is attempted.
So ECONNREFUSED narrows the question to: why is nothing listening at exactly this address and port? The common answers:
- The service is not running (crashed, never started, still starting).
- Wrong port or host, often from a missing environment variable falling back to a default.
- Address family mismatch. Node 17 changed
dns.lookupto return addresses in the order the operating system gives them (“verbatim”) instead of putting IPv4 first. On many systemslocalhostresolves to::1first. A server that listens only on127.0.0.1(some local database setups, some Docker port mappings, servers started withhost: '127.0.0.1') refused the::1attempt, giving the confusingconnect ECONNREFUSED ::1:5432. Node 20 turned onautoSelectFamilyby default, sonet.connectnow tries each resolved address in turn (“Happy Eyeballs”). That fixes the mismatch for most clients, and when everything fails it reports theAggregateErrorshown above. Clients that resolve the name themselves and connect to a single IP do not get this fallback. - Container networking. Each container has its own network namespace, so its
127.0.0.1is not your laptop’s and not the database container’s.
Step-by-step walkthrough
Step 1: Read the address and port Node tried
The address and port properties are after DNS resolution, which makes them more reliable than your config file. ::1 means IPv6 loopback, 127.0.0.1 IPv4 loopback, and a private IP such as 172.18.0.3 means a container or remote host. Log it in your error handler so you do not have to guess:
function describeConnectError(err) {
const errors = err.errors ?? (err.cause?.errors) ?? [err.cause ?? err];
return errors.map((e) => `${e.code} ${e.address}:${e.port}`).join(', ');
}Step 2: Check what is listening, from where the client runs
lsof -nP -iTCP:5432 -sTCP:LISTENLook at the NAME column. 127.0.0.1:5432 accepts only IPv4 loopback, [::1]:5432 only IPv6 loopback, and *:5432 accepts every address. If nothing is printed, nothing is listening: start the service and read its logs to see why it is not up.
A one-line probe from Node itself avoids differences between tools:
node -e "require('net').connect(5432,'127.0.0.1').on('connect',()=>{console.log('open');process.exit(0)}).on('error',e=>{console.log(e.code);process.exit(1)})"Run it on the same machine or in the same container as your app; checking from your laptop proves nothing about a container.
Step 3: Fix host and address family mismatches
If the server listens on 127.0.0.1 and you see ::1 in the error, either connect to 127.0.0.1 explicitly or make the server listen on both families. Prefer explicit addresses in local configuration:
DATABASE_URL=postgres://app:secret@127.0.0.1:5432/appChanging Node’s global DNS order with --dns-result-order=ipv4first also works, but it changes every lookup in the process to work around one service’s bind address, so keep it as a last resort.
Step 4: Get Docker networking right
Inside a container, localhost is the container. To reach another service in the same Compose project, use its service name; Compose’s internal DNS resolves it to that container’s IP. The ports: mapping only matters for traffic from your host into containers; container-to-container traffic uses the internal port directly. To reach a process running on your host from a container, use host.docker.internal (Docker Desktop provides it; on Linux add extra_hosts: ["host.docker.internal:host-gateway"]). Also make sure that host process listens on an address the container can reach, not just 127.0.0.1.
Step 5: Retry while dependencies start
Even with correct addresses, an app that starts faster than its database will see ECONNREFUSED for the first few seconds. Retry connection attempts with exponential backoff, jitter and a cap, and only for errors that mean “not ready yet”:
import net from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
function tryConnect(host, port, timeoutMs = 2000) {
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port, timeout: timeoutMs });
socket.once('connect', () => { socket.end(); resolve(); });
socket.once('timeout', () => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })));
socket.once('error', reject);
});
}
export async function waitForPort(host, port, { attempts = 8, baseMs = 250, maxMs = 4000 } = {}) {
for (let attempt = 1; ; attempt++) {
try {
return await tryConnect(host, port);
} catch (err) {
const code = err.code ?? err.errors?.[0]?.code;
if (!RETRYABLE.has(code) || attempt >= attempts) throw err;
const delay = Math.min(maxMs, baseMs * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);
console.warn(`${host}:${port} not ready (${code}), retry ${attempt}/${attempts - 1} in ${Math.round(delay)}ms`);
await sleep(delay);
}
}
}With a server that starts 1.2 seconds late, this printed four retries and then connected. The attempt limit matters: if the database is truly down, the process should fail with a clear error so the orchestrator can report it, instead of waiting forever.
Worked scenario
A developer moves an API into Docker Compose. It worked on the laptop with a locally installed Postgres. The .env file still says:
DATABASE_URL=postgres://app:secret@localhost:5432/appThe API container logs connect ECONNREFUSED 127.0.0.1:5432 (or an AggregateError [ECONNREFUSED] listing both ::1 and 127.0.0.1, depending on the image) and exits. Running docker compose exec api node -e "..." with the probe from Step 2 shows ECONNREFUSED, while the same probe in the db container shows open. Postgres is fine; the API is knocking on its own container’s port 5432. On a second run, after the hostname is fixed, the API still fails occasionally because it starts before Postgres finishes initialising.
Fixed compose.yaml:
services:
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 2s
timeout: 3s
retries: 15
api:
build: .
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
depends_on:
db:
condition: service_healthyThe hostname db resolves to the database container, and condition: service_healthy holds the API back until pg_isready succeeds. Keep the application-level retry as well: health checks cover startup, but databases also restart during upgrades and failovers while the API is running.
Common mistake
The tempting fix is wrapping the connection in an infinite while (true) retry loop. It hides configuration errors (a wrong host never becomes right), keeps a broken pod looking “running” so nobody gets paged, and with no backoff it hammers the dependency the moment it comes back. Bounded retries with backoff for startup, plus a clear crash when the limit is reached, give you both resilience and visibility.
Another is “fixing” ::1 errors by disabling IPv6 on the machine or forcing ipv4first globally, when the real problem is that the service was not running at all. Check for a listener first; the address family only matters once something is listening.
Finally, connection pools do not fix ECONNREFUSED. A pool with no reachable server just fails each checkout; it needs the same correct host and the same startup strategy.
Verify the behavior
- The Node probe from Step 2, run inside the client’s environment, prints
open. lsof -nP -iTCP:5432 -sTCP:LISTENshows a listener on an address that matches the one in your connection string.docker compose upshows thedbservice become healthy beforeapistarts, and the API logs a successful connection on its first attempt.- Stop the database while the API is running, then start it again: the API logs a few retries and recovers, or exits with a clear message after the attempt limit, which is what you designed.
Interview exercise
“After upgrading a project from Node 16 to Node 18, local integration tests fail with connect ECONNREFUSED ::1:6379. Redis is running and redis-cli connects fine. What changed, and how would you fix it?”
Answer and reasoning
Node 17 changed DNS resolution order from “IPv4 first” to the operating system’s order. On this machine localhost resolves to ::1 first, but Redis is bound only to 127.0.0.1, so the IPv6 attempt is refused. redis-cli works because it defaults to 127.0.0.1, so the server really is up. Node 18 does not fall back to the next address by default; Node 20 enabled autoSelectFamily, which would try 127.0.0.1 next.
The cleanest fix is to make the address explicit in test configuration (redis://127.0.0.1:6379) or have Redis listen on both loopback addresses. Upgrading to a current LTS also helps because of the automatic family fallback, though clients that resolve hosts themselves may still connect to one address only. I would not set --dns-result-order=ipv4first globally unless there were many such services, because it changes behaviour for every lookup to hide one configuration detail. The reasoning an interviewer is looking for: ECONNREFUSED is a fast, definite answer from the target host, so you compare the exact address in the error with where the server listens, rather than suspecting the network.
Continue learning
- Node.js interview questions and Node.js MCQs
- Node.js retries with backoff and jitter
- Node.js database pools and concurrency
- Error: listen EADDRINUSE in Node.js, the server-side counterpart
- Official reference: Node.js common system errors, dns.setDefaultResultOrder and Docker Compose startup order