ERR_CONNECTION_REFUSED: Fix It in 5 Minutes
Start the server and match the port, then retry — most ERR_CONNECTION_REFUSED cases come from nothing listening or a wrong port, and take minutes to fix..
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓A local dev server you can start and stop (Node, Vite, or Express)
- ✓A terminal where you can run curl against localhost
- ✓Basic comfort reading browser DevTools Network panel
- The server isn't running or it crashed — start it and wait for the listening log line before reloading the browser
- The page calls the wrong port or host — diff the fetch URL against the server's listen line character by character
- localhost, 127.0.0.1, and 0.0.0.0 aren't the same — 0.0.0.0 is listen-only and never works as a browser address
- Prove it with curl: curl failing too means a server fault, curl working means the bug is in your page
Imagine calling a shop's phone number and hearing 'this line is disconnected' instead of ringing. The phone network works fine — there's just nobody on the other end. ERR_CONNECTION_REFUSED is the same: your browser dialed the right computer but the specific extension number (the port) had no one answering. Either the shop is closed (server stopped), you dialed the wrong extension (wrong port), or you called the shop's internal line from outside (localhost inside Docker).
You're staring at a blank app and a red console line: ERR_CONNECTION_REFUSED on every request. The UI code looks right, the fetch URL looks right, and yesterday everything worked. Most developers start rewriting components at this point — new fetch wrappers, cleared storage, a different browser. None of it helps, because the page was never the problem.
This error is the browser telling you something precise: it reached the machine, knocked on the port, and nobody answered. That narrows the suspect list to three things — a server that isn't running, a port or host that doesn't match, or a container boundary that moved loopback under your feet. Each has a 30-second check, and this guide gives you all of them.
You'll learn to read the error literally, tell localhost apart from 127.0.0.1 and 0.0.0.0, survive Docker's per-container loopback, handle https-to-http quirks, and use curl to split every case into server fault or page fault in one command. Keep this page open during your next red console and work it top to bottom.
Nothing Is Listening: The Server Isn't Running or Crashed
Refused is the most literal error in the browser: your TCP SYN packet reached the host, and the host replied RST — no queue, no backlog, nothing. Contrast that with a timeout (packets vanish, cause unknown) or a DNS failure (you never found the host). RST means the road works and the door is shut. So the first question is never 'is my code right?' but 'is any process holding that port open right now?' That check takes seconds: look at the server terminal for a listening line, or run curl -v and watch for an instant refusal instead of a hang.
A crashed-at-boot server is the classic. Nodemon or ts-node restarts, the app throws on an import, the process exits, and the port goes quiet — while the browser tab keeps retrying the old address. You'll see refused on every request with zero server logs, because there is no server. Scroll the terminal up: the real error (a bad import, a missing env var, a port clash) is sitting above the last restart line. Fix that, watch the process stay up for ten seconds, and only then reload the tab.
The second classic is simpler: the server was never started. Frontend and backend live in different terminals, the machine rebooted, or you opened the project straight into the UI folder. Make startup a single command (npm run dev with concurrently, or Compose up for both sides) so one action can't leave half the system down. And give the API a /health route — a 200 from /health is the cheapest proof of listening you'll ever own, and your deploy pipeline can curl it too.
Wrong Port or Host: The Typo That Wastes an Hour
Once a listener exists somewhere, the next suspect is the address you knocked on. Ports are exact numbers with no fuzzy matching: :3000 and :3001 are as different as two cities. Frontend env vars (VITE_API_URL, REACT_APP_API_URL, API_BASE_URL) go stale silently — a backend move from 3000 to 4000, a second project grabbing 3000 first, or a README example you copied with a different port. The symptom is maddeningly stable: every request refused, server healthy, because you're calling an empty port two digits away from the live one.
Build a habit that kills this class forever: log the full base URL when the frontend boots, and read the server's listen line when it starts. Put the two side by side — if they differ, you've found it in seconds. Centralize the value so there's exactly one place to update: one env var, one config module, every fetch built from it. Never scatter literal 'http://localhost:3000' strings across ten files, because the eleventh file you forgot is the one that pages you.
Watch for the port-squat variant too: something else already owns your port, so your server either crashed with EADDRINUSE or quietly bound elsewhere. If the server log shows an unexpected port, run lsof -i :3000 (macOS/Linux) or netstat -ano | findstr :3000 (Windows) to name the squatter. Kill the stale process or move your server deliberately — but never leave two services fighting over one port and guess which one won.
localhost vs 127.0.0.1 vs 0.0.0.0: What Each One Means
Three strings that look interchangeable will ruin your afternoon if you treat them as identical. localhost is a hostname that usually resolves to 127.0.0.1 (IPv4) or ::1 (IPv6) — whichever your resolver prefers that day. 127.0.0.1 is the IPv4 loopback address, always IPv4, no DNS involved. 0.0.0.0 is not a destination at all: it's the wildcard a server binds to mean 'accept on every local interface'. Each has one correct position — dial versus listen — and swapping them breaks things in ways that look haunted.
The IPv4/IPv6 split is the haunting. Your Node server listens on 127.0.0.1 (IPv4 only), but the browser resolves localhost to ::1 (IPv6) and knocks there — refused, while 127.0.0.1 in the address bar works fine. Or the reverse after an OS update flips resolution order. When localhost fails but 127.0.0.1 succeeds, you've found it: either bind the server to both families (listen without a host argument) or standardize the URL on 127.0.0.1. Don't reorder resolver config to fix one app.
Then there's the position error: pasting 0.0.0.0 into the browser because the server log said 'listening on 0.0.0.0:3000'. That log line describes the bind, not a dialable address — translate it to localhost on the same machine. Going the other way, binding to 127.0.0.1 and expecting your phone on the same Wi-Fi to connect will refuse every time: loopback never leaves the machine. For LAN testing, bind 0.0.0.0 and dial the machine's LAN IP (192.168.x.x), keeping the firewall in mind on public networks.
Docker Loopback Trap: Why localhost Fails in Containers
Docker gives every container its own loopback interface, and that breaks every localhost assumption your code grew up with. Code that fetches http://localhost:3000 works on your laptop because app and API share one loopback. Move the caller into a container and localhost now means the caller's own container — the API isn't there, so the connection is refused instantly with no server-side trace. The API logs show nothing because the request never left the wrong house.
The fix is naming: inside Compose, services reach each other by service name over the shared network — http://api:3000 when the service is called api. Only the host uses localhost, and only through a published port (ports: ['3000:3000']). Keep the two call sites straight: browser-on-host talks to localhost:3000, containerized worker talks to http://api:3000. An env switch like RUNNING_IN_DOCKER picks the right base per runtime so one codebase serves both.
Two companion traps ride along. First, a server inside a container that binds 127.0.0.1 is reachable only within its own container — bind 0.0.0.0 inside Docker so the published port can forward in. Second, port publishing itself: without ports: in Compose (or -p 3000:3000 in plain Docker), the host's localhost:3000 has nothing to forward to. When host-side calls refuse but container-to-container works, check the publish mapping with docker ps before anything else.
https-to-http and Browser vs curl: Isolating the Layer
Two failures wear the refused costume. The first is scheme mismatch: an https page fetching http://localhost gets blocked as mixed content. Chromium blocks it outright; the console message talks about insecure requests while your API sits healthy on plain http. It works when you open the page over http, which sends you chasing ghosts. The durable fix is one scheme everywhere in dev — proxy /api through the dev server so the browser sees a single origin, or serve local https on both sides with mkcert.
The second is blaming page code for a transport failure. Extensions (ad blockers, privacy tools), proxy env vars, and service workers can each break or reroute a request the server would have answered. curl cuts through all of it: curl -v http://localhost:3000/health speaks raw HTTP with no extensions, no worker, no cached DNS. If curl succeeds while the page fails, work the page side — disable extensions in a fresh profile, unregister the service worker, check proxy bypass for localhost. If curl fails, stay on the server side.
Make the split a reflex and in this order: server log, curl, then browser. The log proves a listener, curl proves reachability without browser interference, and only then does DevTools earn your attention. Teams that run this order resolve refused cases in minutes; teams that start in the Components panel donate afternoons. The browser's Network tab is step three, never step one.
The 5-Minute Fix Checklist That Ends Refused Errors
When the console turns red, run this sequence and stop at the first failure. One: read the server terminal — is the process alive and does it print a listening line with the port you expect? If it exited, fix the boot error above the last restart. Two: curl -v your health endpoint with the exact host and port from that log line. Instant refused means server-side; success means page-side. Three: diff the page's base URL against the log line digit by digit, then restart the dev server so fresh env vars load — Vite and CRA bake them at boot.
Four: check the host position. Browser URL with 0.0.0.0 is always wrong — swap to localhost. localhost failing while 127.0.0.1 works means an IPv4/IPv6 split — standardize on 127.0.0.1 or bind both families. In Docker, container callers use the service name and the image binds 0.0.0.0 with a published port. Five: compare schemes — https page plus http API means proxy /api through the dev server. Six: retest in a clean profile with extensions off and the service worker unregistered.
Write down what you found, even if it's 'server wasn't started'. The note feels silly once, then saves the next person an hour. Better: log the API base URL at frontend boot and expose /health on every backend, so the next refusal announces its own cause in the first two lines of output.
The One-Port Deploy Move That Broke Every Dashboard for 38 Minutes
- Log the full API base URL on frontend startup — a wrong port then shows up in the first log line, not after an hour of component debugging.
- Gate deploys on a health check the pipeline curls itself; a deploy that can't reach its API should fail before users see it.
- Keep port mappings and frontend env examples side by side so an infra change forces an env review in the same diff.
| File | Command / Code | Purpose |
|---|---|---|
| health-check.js | async function isServerUp(baseUrl) { | Nothing Is Listening |
| api-client.js | const API_BASE = (process.env.API_BASE_URL || 'http://localhost:3000').replace(/... | Wrong Port or Host |
| addrs.js | function dialUrl(bindHost, port) { | localhost vs 127.0.0.1 vs 0.0.0.0 |
| compose-url.js | function apiBase() { | Docker Loopback Trap |
| vite.config.js | module.exports = { | https-to-http and Browser vs curl |
Key takeaways
Common mistakes to avoid
5 patternsTyping the port from memory instead of the server log
Binding to 127.0.0.1 and expecting LAN devices to connect
Fetching localhost from inside a Docker container
Loading an https page that fetches http://localhost
Debugging JavaScript before testing with curl
Interview Questions on This Topic
What does ERR_CONNECTION_REFUSED mean at the network level?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Browser. Mark it forged?
6 min read · try the examples if you haven't