Home JavaScript ECONNREFUSED in Node.js: Fix Refused Connections Fast
Beginner 7 min · September 23, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 12 min
  • 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
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 :3000 dials an empty socket.
  • Swap localhost for 127.0.0.1 to rule out IPv6/IPv4 mismatch, and bind dev servers to 0.0.0.0 inside Docker.
  • Prove it with curl -v plus ss -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) because localhost in a container points at itself.
✦ Definition~90s read
What is ECONNREFUSED Connection Refused Fix?

ECONNREFUSED is an operating-system error code, not a JavaScript exception type. When your code opens a TCP connection, the OS sends a SYN packet to the target host and port. If that port is closed — no process called listen() on it — the remote kernel (or your own, for localhost) replies with RST: connection reset, door shut.

Imagine dialing a shop and hearing 'this number is not in use' half a second later.

Your OS translates that RST into the errno ECONNREFUSED, and Node's networking layer surfaces it through libuv to your fetch call. With the modern undici-based fetch in Node 18 and later, the raw error gets wrapped: you see TypeError: fetch failed, and the real diagnosis hides one level down in error.cause, where cause.code equals ECONNREFUSED and cause.message names the address, as in connect ECONNREFUSED 127.0.0.1:3000.

Three everyday situations produce it. The server is down, crashed, or never started — the most common case in development. The port is wrong — your API moved from 3000 to 4000 but the frontend still calls the old one. Or the host is wrong — you dialed localhost while the server bound to a container IP, or you reached the right machine but the server listens on a different interface only.

Firewalls with REJECT rules can also surface as refused, though most firewall defaults DROP silently instead.

Just as important is what ECONNREFUSED is not. It is not a timeout: timeouts (ETIMEDOUT) mean packets vanished without an answer, while refused means you got an answer — no. It is not a DNS failure: unknown hosts fail with ENOTFOUND before any packet is sent.

It is not a server crash mid-request or a 500 status: those require a connection that succeeded first. And it is not CORS: browsers report refused connections as failed network requests, and no header can fix a server that is not listening.

Plain-English First

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.

diagnose-refused.shBASH
1
2
3
4
5
6
7
8
9
10
11
# Confirm the refusal and time it (instant = refused, hang = timeout/firewall DROP)
curl -v --max-time 5 http://localhost:3000/health

# Linux: who listens on port 3000? No row = nothing listening.
ss -tlnp | grep ':3000'

# macOS equivalent
lsof -i :3000

# Confirm Node itself is refused (proves it is not a browser quirk)
node -e "fetch('http://localhost:3000/health').catch(e => console.log('code:', e.cause && e.cause.code))"
📊 Production Insight
In the staging incident, curl -v answered in 2 ms with Connection refused while the team restarted containers for 20 minutes. One ss -tlnp check would have shown port 3000 empty and ended the debate before the first restart.
🎯 Key Takeaway
A SYN answered by RST becomes ECONNREFUSED in milliseconds — instant failure plus no LISTEN row proves no server listens there.

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.

server.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import http from 'node:http';

const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '0.0.0.0';

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ ok: true }));
    return;
  }
  res.writeHead(404);
  res.end('not found');
});

server.listen(PORT, HOST, () => {
  console.log('listening on', server.address());
});
Try it live
📊 Production Insight
A teammate once burned an hour because localhost resolved to ::1 on a fresh Mac while the API bound IPv4 only. The fix was a single bind argument, but the lesson stuck: always verify with both spellings before assuming the server is down.
🎯 Key Takeaway
localhost resolves per-connection while servers bind per-family — mismatch means refused, and binding 0.0.0.0 ends the feud in dev.

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.

fix-order.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
# 1. Is the API process listening? Expect a LISTEN row for your port.
ss -tlnp | grep ':4000'

# macOS equivalent
lsof -i :4000

# 2. Does the client URL match the server's bound port? Grep, do not guess.
grep -rn 'localhost:3000\|localhost:4000' --include='*.js' --include='*.env*' .

# 3. Rebind dev servers to all interfaces, then confirm the ss row reads 0.0.0.0
PORT=4000 HOST=0.0.0.0 node server.js
curl -v http://127.0.0.1:4000/health
📊 Production Insight
The staging post-mortem showed the team worked the order backwards: firewall theories first, port diff last. The checklist now hangs in the runbook with step two highlighted, because the port had been the answer all along.
🎯 Key Takeaway
Check in order — process listening, port match, bind address, then Docker and firewall — and stop at the first failure.

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.

docker-localhost-check.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
# Which containers run, and what did they publish?
docker compose ps
docker port myapp-api-1 3000

# From the HOST: does the published port answer?
curl -v http://localhost:3000/health

# From INSIDE the calling container: does the service name answer?
docker compose exec frontend sh -c "apk add --no-cache curl >/dev/null 2>&1; curl -v http://api:3000/health"

# Container -> laptop service (dev only): use the special host name
# fetch('http://host.docker.internal:3000/health')
📊 Production Insight
A frontend container calling localhost:3000 burned a whole sprint of goodwill before someone ran docker exec into it and curled the api service name successfully. The one-word URL fix shipped in minutes; the lesson about namespaces lasted.
🎯 Key Takeaway
Each container owns its loopback, so siblings talk via service names and host programs via host.docker.internal — never localhost.

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.

fetch-check.mjsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const url = process.argv[2] || 'http://localhost:3000/health';

try {
  const res = await fetch(url);
  console.log('status:', res.status);
  console.log(await res.text());
} catch (err) {
  console.log('outer message:', err.message);
  console.log('cause code:   ', err.cause?.code);
  console.log('cause message:', err.cause?.message);
  if (err.cause?.code === 'ECONNREFUSED') {
    console.log('diagnosis: nothing listens on that host:port — start the server or fix the URL.');
  }
  process.exitCode = 1;
}
Try it live
💡Never Match on the Outer Message
String-matching 'fetch failed' merges refused connections, DNS failures, and timeouts into one bucket. Branch on err.cause.code instead — it is stable across Node versions while the outer message text is not.
📊 Production Insight
During the staging incident, frontend logs printed only fetch failed, so three failure classes looked like one. The team now logs cause code on every network error, and the next refusal was diagnosed from the log line alone.
🎯 Key Takeaway
Log err.cause.code, not just the message — ECONNREFUSED, ENOTFOUND, and ETIMEDOUT each demand a different fix.

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.

wait-for-server.mjsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function waitForServer(url, { tries = 30, delayMs = 1000 } = {}) {
  for (let i = 1; i <= tries; i++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
      console.log(`attempt ${i}: server answered with status ${res.status}`);
      return res;
    } catch (err) {
      console.log(`attempt ${i}: ${err.cause?.code || err.message} — retrying...`);
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
  throw new Error(`server at ${url} never accepted a connection`);
}

await waitForServer('http://localhost:3000/health');
Try it live
📊 Production Insight
The staging ticket bounced between frontend and backend twice because DevTools said Failed to fetch while the terminal said ECONNREFUSED. Reproducing the URL in curl took ten seconds and reunited the two reports into one bug.
🎯 Key Takeaway
Browsers hide transport codes for security while Node exposes them — reproduce browser refusals in curl to get the real code.
● Production incidentPOST-MORTEMseverity: high

The Staging Deploy Where the API Changed Ports and Checkout Broke

Symptom
The checkout button spun, then showed Network error on every attempt. Backend logs were spotless — zero requests arrived. Frontend logs showed TypeError: fetch failed with cause ECONNREFUSED 127.0.0.1:3000 on 100% of checkout calls while the status page stayed green.
Assumption
The on-call engineer assumed the deploy had crashed the API, so two engineers restarted containers and rolled the frontend back to the previous build. When the rollback changed nothing, suspicion moved to the staging firewall because a security-group change had shipped that same morning — another plausible story that also turned out wrong.
Root cause
The backend PR moved the API from port 3000 to 4000 to dodge a clash with a new mock service, and docker-compose.yml published 4000:4000. The frontend's API URL still pointed at http://localhost:3000, where nothing listened — the kernel answered each SYN with RST and Node surfaced ECONNREFUSED. The uptime monitor polled /health on :4000, which is exactly why it stayed green while checkout burned.
Fix
Three concrete changes shipped the same afternoon. First, the frontend env var was pointed at http://localhost:4000 and redeployed — checkout recovered within 4 minutes. Second, both services now read their ports from one documented compose section with comments naming the consumer, and the API logs its bound address and port through 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.
Key lesson
  • 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.
Production debug guideFive refusal patterns with the exact commands that prove each one — run them in order before changing code.5 entries
Symptom · 01
fetch fails instantly with TypeError: fetch failed
Fix
Run 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.
Symptom · 02
You are not sure anything listens on the port
Fix
Run 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.
Symptom · 03
localhost fails but 127.0.0.1 works, or the reverse
Fix
Try both 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.
Symptom · 04
Refused only when the client runs inside Docker
Fix
Run 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.
Symptom · 05
Local curl works but remote hosts get refused
Fix
Check the bind address first: if 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.
ECONNREFUSED Causes Compared — Confirm Before You Fix
Root CauseHow to ConfirmFixPrevention
Server not started or crashedcurl -v fails instantly; ss -tlnp | grep PORT shows no LISTEN rowStart the server; fix the crash; rerun the clientProcess manager (pm2/systemd) plus boot log of bound address
Wrong port in client URLServer LISTENs on one port while the client calls another; logs disagreeCorrect the client URL or env var to the real portSingle source of truth for ports; fail builds on mismatch
Server bound to 127.0.0.1, reached remotelyss -tlnp shows 127.0.0.1:PORT; local curl works, remote gets refusedRebind to 0.0.0.0 for dev and LAN accessLog server.address() on boot; probe from a second host
localhost used inside a Docker containerWorks on the host, refused in the container with the same URLUse the Compose service name or host.docker.internalDocument container networking; never hardcode localhost
Firewall REJECT rule on the portLocal connects fine; remote gets refused and iptables -L -n shows REJECTOpen the port for the client IP or subnetDefault-deny with explicit allows reviewed in version control
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
diagnose-refused.shcurl -v --max-time 5 http://localhost:3000/healthWhat ECONNREFUSED Means at the TCP Level
server.jsconst PORT = Number(process.env.PORT) || 3000;localhost vs 127.0.0.1 vs 0.0.0.0
fix-order.shss -tlnp | grep ':4000'The Four-Step Fix Order That Ends Refused Connections
docker-localhost-check.shdocker compose psDocker's localhost Trap
fetch-check.mjsconst url = process.argv[2] || 'http://localhost:3000/health';Reading Node's fetch Error
wait-for-server.mjsasync function waitForServer(url, { tries = 30, delayMs = 1000 } = {}) {Browser vs Node

Key takeaways

1
ECONNREFUSED means nothing listened on that host and port
the OS answered your SYN with RST, so chase the server side first.
2
Fix in order
start the server, correct the port, fix the bind address, then suspect Docker networking or firewall REJECT rules.
3
localhost is a resolved hostname while 127.0.0.1 is numeric IPv4
test both when one of them is refused.
4
Inside Docker, localhost points at the container itself
use Compose service names or host.docker.internal instead.
5
Node 18+ wraps the failure as TypeError
fetch failed — the real diagnosis lives in err.cause.code.
6
Prove each hypothesis with curl -v and ss -tlnp before changing code; instant failure plus no LISTEN row ends the debate.

Common mistakes to avoid

6 patterns
×

Restarting the client instead of checking whether anything listens

Symptom
Engineers restart the frontend, clear caches, and reinstall node_modules while the backend was never started. Twenty minutes burn with zero change because every attempt still dials an empty port.
Fix
Read err.cause.code before touching anything. Run 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

Symptom
Seed scripts and integration tests fail with ECONNREFUSED only on fresh boots or in CI. Re-running them seconds later passes, which makes the failure look flaky when it is really a startup race.
Fix
Make the client wait for the server. Use 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

Symptom
The server moves from 3000 to 4000 in one file while the frontend still calls :3000. Both halves look correct in isolation, and the mismatch survives code review because no single diff shows both sides.
Fix
Keep the port in one place: read it from 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

Symptom
curl works on the server itself but every remote check, phone-on-Wi-Fi test, or container health probe gets refused. The server runs fine; it simply never listened on an externally reachable interface.
Fix
Bind dev servers to 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

Symptom
Code that works on the laptop fails the moment it moves into Docker with the identical URL. Developers blame the image or the network mode when localhost inside the container simply points at the container itself.
Fix
From a container, reach host services through 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

Symptom
Teams add 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.
Fix
Treat CORS as guilty only after a connection succeeds. If 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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does ECONNREFUSED mean when a Node.js fetch fails?
Q02JUNIOR
Why can localhost fail while 127.0.0.1 works?
Q03SENIOR
Why does fetch to localhost fail inside a Docker container?
Q04SENIOR
Why does Node show TypeError instead of ECONNREFUSED for fetch?
Q05SENIOR
How do you distinguish a firewall REJECT from a dead server?
Q01 of 05JUNIOR

What does ECONNREFUSED mean when a Node.js fetch fails?

ANSWER
It means the TCP handshake failed because no process was listening on that host and port — the OS answered the SYN packet with RST. I would verify with curl -v and ss -tlnp, then fix in order: start the server, correct the port, fix the bind address, and only then suspect Docker or firewall rules.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is localhost exactly the same as 127.0.0.1?
02
What is the difference between ECONNREFUSED and ECONNRESET?
03
Is it safe to bind my dev server to 0.0.0.0?
04
Can adding CORS headers fix ECONNREFUSED?
05
Should my fetch retry on ECONNREFUSED?
06
How do I tell a Docker port-mapping bug from an app bug?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Node.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
CORS Policy Error Fix
20 / 30 · Node.js
Next
npm ERESOLVE Dependency Conflict Fix