ECONNREFUSED in Node.js: Fix Refused Connections Fast
ECONNREFUSED means nothing listens on that host and port, so start the server, correct the port, and check the bind address.
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
- ✓Basic JavaScript with Node.js 18+ installed locally
- ✓A local API server you can start, stop, and reconfigure
- ✓Terminal comfort running curl and reading error output
- Start the backend first, then retry the request — ECONNREFUSED means your fetch arrived but nothing listened on that host and port.
- Match the port exactly: if the server logs
listening on 4000, fetch:4000, since:3000dials an empty socket. - Swap
localhostfor127.0.0.1to rule out IPv6/IPv4 mismatch, and bind dev servers to0.0.0.0inside Docker. - Prove it with
curl -vplusss -tlnp: instant failure with no LISTEN row blames the server side, not your code. - In Docker Compose, call the service by name (
http://api:3000) becauselocalhostin a container points at itself.
Imagine dialing a shop and hearing 'this number is not in use' half a second later. No busy tone, no hold music, no ringing out — the network found no phone plugged in at that number and hung up. That is ECONNREFUSED. Your Node app dialed a host and port, the OS knocked, no server program was listening there, and the OS refused instantly. The fix mirrors the analogy: check the shop is open (start the server), dial the right number (correct the port), and call the right branch (correct host).
You are building a Node.js app, the backend is supposedly running, and your fetch to http://localhost:3000/api explodes with TypeError: fetch failed and a buried connect ECONNREFUSED 127.0.0.1:3000. Nothing in your code looks wrong, yet every request dies within milliseconds. You will meet this error in your first week with Node, and you will keep meeting it for years — across dev servers, Docker setups, and staging deploys, a refused connection is always one typo away.
Here is the good news: ECONNREFUSED is one of the most honest errors in computing. It does not mean your code is broken or the network is haunted. It means exactly one thing — nothing listened on the host and port you contacted. The operating system knocked, received a TCP RST packet in reply, and told Node the door was shut. That precision makes it fixable in minutes once you know the checklist.
This guide hands you that checklist in the order that works: confirm the server runs, match the port, fix the listen address, then look at Docker and firewalls. You will learn why localhost and 127.0.0.1 sometimes behave differently, why containers cannot use localhost to reach your laptop, and how to read Node's wrapped fetch error to pull out the real cause code. By the end, a refused connection will feel routine instead of alarming.
What ECONNREFUSED Means at the TCP Level
Every refused connection starts the same way: your program asks the OS to open a TCP socket to some host and port. The OS sends a SYN packet — the opening knock of the three-way handshake. When a server process has called listen() on that port, the kernel answers SYN-ACK and the conversation begins. When nothing listens, the kernel answers with RST, a flat no. Your OS converts that RST into the errno ECONNREFUSED, Node's libuv layer picks it up, and undici wraps it as TypeError: fetch failed with the code tucked inside error.cause. The whole exchange takes about a millisecond on localhost, which is why refused requests fail fast instead of hanging.
That speed is your best diagnostic clue. A refusal answers immediately, while timeouts, DNS stalls, and firewall DROPs all take seconds. So when fetch dies in 2 ms, you already know the network path works and the port is closed — there is no need to blame DNS, proxies, or your ISP. Run curl -v against the same URL and read its verdict: Failed to connect with Connection refused confirms the story independently of Node. Pair it with ss -tlnp (or lsof -i on macOS) to see every listening socket on the machine. If your port is absent from that list, the investigation is over: start something on that port or point the client elsewhere.
Treat this mechanism as a contract. Refused always means no listener, never means the server is slow, overloaded, or mid-crash. Internalizing that contract saves you from the two classic wastes: adding retry storms against a port nobody serves, and tuning timeouts for a handshake that already got its answer.
localhost vs 127.0.0.1 vs 0.0.0.0: Three Addresses, Three Behaviors
localhost feels like a fixed address, but it is really a nickname your OS resolves on every connection. Most systems map it to 127.0.0.1 (IPv4), many also map it to ::1 (IPv6), and the winner depends on resolver order you never configured. Meanwhile your server binds to whatever you told it: 127.0.0.1 serves IPv4 loopback only, ::1 serves IPv6 only, and 0.0.0.0 serves every IPv4 interface on the machine. When the client's resolved address and the server's bound address belong to different families, the handshake never meets a listener and you get refused — even though both sides look correct alone.
The classic case: a Node server started with server.listen(3000, '127.0.0.1') while the client dials localhost, which resolves to ::1 first. IPv6 knock, IPv4 listener, instant RST. The reverse happens too — a server bound to ::1 with a client pinned to 127.0.0.1. Binding to 0.0.0.0 sidesteps the whole family feud in development by serving all IPv4 interfaces, and inside Docker it is mandatory because container traffic arrives on the container's eth0, never its loopback. The tiny server below shows the pattern: listen on 0.0.0.0 for dev, log server.address() so the terminal prints the truth, and let PORT come from the environment so client and server share one value.
When you inherit a refusal, test all three spellings — localhost, 127.0.0.1, and the LAN IP — before changing code. If exactly one spelling works, you have found an address-family mismatch, not a dead server, and the fix is one bind argument.
The Four-Step Fix Order That Ends Refused Connections
Refused connections reward boring discipline: check causes in a fixed order and stop at the first one that fails. Step one, prove the server process exists — ss -tlnp on Linux or lsof -i :PORT on macOS must show a LISTEN row for your port. No row means the server is down, crashed, or never started, and no client-side change can help. Start it, watch the boot log print its bound address, and curl the health endpoint from the same machine before involving any other layer.
Step two, reconcile the port. Read the server's actual listen log (listening on 0.0.0.0:4000) and diff it against the client's URL character by character. Frontend env files, proxy configs, and hardcoded URLs drift silently, so grep the client codebase for the old port instead of trusting memory. If the two disagree, change the client — moving the server back just to match a stale URL trades one outage for the next port clash.
Step three, reconcile the bind address. A server on 127.0.0.1 serves only its own machine; anything remote — a phone on Wi-Fi, a container, a teammate's laptop — gets refused. Rebind to 0.0.0.0 for anything beyond solo development, and confirm the ss row changed accordingly. Only after these three pass should you spend time on step four, the exotic layer: Docker's localhost trap and firewall REJECT rules. This order matters because each step takes seconds and eliminates the most common cause first — the teams that jump to firewalls debug for an hour what a port diff would have shown in ten seconds.
Docker's localhost Trap: Your Container Talks to Itself
Docker turns localhost into a trap because every container gets its own private loopback interface. Code that fetches http://localhost:3000 works on your laptop — then fails inside a container with the identical URL, because localhost now means this container, not your laptop and not the sibling API container. Nothing is misconfigured; the name simply resolves in a different network namespace. Developers blame images, rebuild layers, and toggle network modes while the real problem is one word in the URL.
The fix follows one rule: inside Compose, address siblings by service name. If docker-compose.yml defines a service called api listening on 3000, other services reach it at http://api:3000 — Docker's embedded DNS resolves the name to the container's real IP. Traffic from a container back to a program on your laptop uses the special name http://host.docker.internal:3000 instead. Traffic from your laptop into a container needs a published port, ports: ['3000:3000'], verified with docker port <container> 3000. Debug it layer by layer: curl the published port from the host first, then exec into the calling container and curl the service name from there. Whichever layer fails names the broken mapping.
Two Compose habits prevent most of this pain. First, give depends_on a real healthcheck condition so the frontend starts after the API passes its health endpoint instead of racing it during cold boots. Second, keep every cross-service URL in environment variables with the service name baked in, never localhost, so the same Compose file works on every teammate's machine without edits.
Reading Node's fetch Error: The cause.code That Names the Culprit
Node 18 and later ship fetch built on undici, and undici wraps transport failures in a way that hides the diagnosis. What you catch is a bland TypeError: fetch failed — the same outer message for refused connections, DNS failures, and timeouts. The specific truth lives one level down at error.cause: an Error with code ECONNREFUSED and a message like connect ECONNREFUSED 127.0.0.1:3000. If you log only the outer message, every network failure looks identical and you debug blind.
The runnable snippet below shows the habit to build: catch, then print err.cause.code and err.cause.message alongside the outer text. That single code field splits your runbook three ways — ECONNREFUSED means find the missing listener, ENOTFOUND means fix DNS or the hostname, ETIMEDOUT means packets vanish (firewall DROP or dead route). Better yet, branch retry logic on the code: retrying a refused localhost port in a tight loop just burns log lines, while retrying with backoff across a deploy restart window is legitimate. Libraries that string-match fetch failed cannot tell these apart and retry everything or nothing.
Browsers hide even more — a refused fetch surfaces as TypeError: Failed to fetch with no cause chain at all, which is why the same outage looks like two different bugs in DevTools and the terminal. When a browser report lands on your desk, reproduce it in Node or curl first to recover the cause code, then debug once with full information instead of twice with half.
Browser vs Node: The Same Refusal Wears Two Masks
The same dead port reports itself differently depending on who knocks, and that split causes real confusion on mixed teams. In Node, you get TypeError: fetch failed with a useful err.cause chain naming ECONNREFUSED and the address. In Chrome, the same fetch rejects with TypeError: Failed to fetch and DevTools shows (failed) net::ERR_CONNECTION_REFUSED in the network tab — no cause chain, no address, no code your catch block can read. Backend engineers see a precise diagnosis while frontend engineers see a shrug, so the two halves of a standup describe what sounds like two bugs.
The underlying event is identical: SYN in, RST out, no listener. The difference is purely presentational — browsers deliberately hide transport details from JavaScript for security, while Node exposes libuv's errno. That means browser-side handling must stay generic: show a connection-error state, suggest checking the server, and log the failing URL with a timestamp. All precise diagnosis happens outside the browser: reproduce the exact URL with curl -v or the Node snippet from the previous section, read the cause code, and fix it once for every client.
One more browser wrinkle worth knowing: extensions, HSTS, and service workers can each produce Failed to fetch for their own reasons, so a refusal confirmed in DevTools still deserves a 10-second curl reproduction. If curl is refused too, the browser is innocent and the server side owns the fix. The snippet below shows a defensive client pattern — a bounded wait for a dev server with clear logging — so slow boots produce a readable error instead of an instant mystery.
The Staging Deploy Where the API Changed Ports and Checkout Broke
server.address() on every boot. Third, the uptime monitor was switched from the raw API health endpoint to a synthetic checkout request using the frontend's configured URL, so the next port mismatch pages within 60 seconds instead of hiding for half an hour.- Monitor what the user touches, not what the server exposes. A green /health check on the right port told nobody that the frontend called the wrong one. Synthetic checks through the real client configuration catch wiring mistakes that unit health checks miss.
- Treat port numbers as shared contracts, not private details. When the backend moved from 3000 to 4000 without updating the frontend env var, two halves of one system disagreed. Keep ports in one documented place and fail the build when client config and server listen ports diverge.
- Read the cause code before restarting anything. ECONNREFUSED names a missing listener, not a crashed process. Two container restarts and a frontend rollback burned 20 minutes; curl -v plus ss -tlnp would have named the empty port in 30 seconds.
curl -v http://localhost:3000/health and watch the timing: Failed to connect ... Connection refused in under a second proves refusal rather than a hang. Then print Node's wrapped cause with node -e "fetch('http://localhost:3000/health').catch(e => console.log(e.cause && e.cause.code, e.cause && e.cause.message))" — if it prints ECONNREFUSED, you are chasing a missing listener and nothing else.ss -tlnp | grep ':3000' on Linux or lsof -i :3000 on macOS. No output means zero processes listen on that port — start the server. A row showing 127.0.0.1:3000 means it listens on loopback only; a row showing 0.0.0.0:3000 means it accepts LAN and container traffic. Match the row against the address your client dials.curl -v http://localhost:3000/health and curl -v http://127.0.0.1:3000/health. If one works and the other is refused, name resolution picked an address family your server does not serve — typically localhost resolving to ::1 while the server bound IPv4 only. Fix it by binding the server to 0.0.0.0 in development or by standardizing clients on the numeric 127.0.0.1.docker compose ps to confirm the API container is up, then docker port <container> 3000 to see the published mapping. From inside a sibling container, curl the service name (http://api:3000/health), and from a container to the host use http://host.docker.internal:3000/health. If the service name works but localhost does not, rewrite the client URL — localhost inside a container is the container itself.ss -tlnp shows 127.0.0.1:3000, remote hosts can never connect — rebind to 0.0.0.0. Then inspect the firewall with sudo ufw status or sudo iptables -L -n | grep -i reject. A REJECT rule answers with refused while a DROP rule hangs until timeout, so instant remote refusal plus a REJECT row names the firewall as the culprit — open the port for the client subnet.| File | Command / Code | Purpose |
|---|---|---|
| diagnose-refused.sh | curl -v --max-time 5 http://localhost:3000/health | What ECONNREFUSED Means at the TCP Level |
| server.js | const PORT = Number(process.env.PORT) || 3000; | localhost vs 127.0.0.1 vs 0.0.0.0 |
| fix-order.sh | ss -tlnp | grep ':4000' | The Four-Step Fix Order That Ends Refused Connections |
| docker-localhost-check.sh | docker compose ps | Docker's localhost Trap |
| fetch-check.mjs | const url = process.argv[2] || 'http://localhost:3000/health'; | Reading Node's fetch Error |
| wait-for-server.mjs | async function waitForServer(url, { tries = 30, delayMs = 1000 } = {}) { | Browser vs Node |
Key takeaways
Common mistakes to avoid
6 patternsRestarting the client instead of checking whether anything listens
curl -v http://localhost:3000/health and ss -tlnp | grep 3000 (or lsof -i :3000 on macOS). If curl says connection refused and no process listens, the server side is at fault — no restart of the client will help.Firing requests before the server finishes booting
npx wait-on http://localhost:3000/health in the dev script, add a small retry with backoff in seed scripts, or use Docker Compose depends_on with a healthcheck condition so dependents start after the API passes its check.Hardcoding the port in two places that drift apart
process.env.PORT on the server and from the same env file in the client. Log the bound address and port on every boot with server.address() so the terminal shows the truth.Binding to 127.0.0.1 and testing from another host
0.0.0.0 when anything outside the machine must reach them: server.listen(3000, '0.0.0.0'). Confirm with ss -tlnp — the row should read 0.0.0.0:3000, not 127.0.0.1:3000.Using localhost inside a container to reach the host
http://host.docker.internal:3000 and reach sibling services by Compose service name (http://api:3000). Publish ports explicitly with ports: ['3000:3000'] when the host must dial in.Blaming CORS for a connection that never happened
Access-Control-Allow-Origin: * to a server that is not even running, then wonder why the browser still fails. CORS headers travel inside HTTP responses, and a refused connection never gets far enough to receive one.curl -v gets refused, no header can help — start the server first. Debug browser failures in DevTools' network tab: (failed) net::ERR_CONNECTION_REFUSED is a TCP problem, not a permissions problem.Interview Questions on This Topic
What does ECONNREFUSED mean when a Node.js fetch fails?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.
That's Node.js. Mark it forged?
7 min read · try the examples if you haven't