On This Page
The Error
Start any Node HTTP server twice on the same port and you get this, verbatim, on Node.js 20.20.2, 21.7.3, and 22.22.2:
node:events:497
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
at Server.setupListenHandle [as _listen2] (node:net:1940:16)
at listenInCluster (node:net:1997:12)
at Server.listen (node:net:2102:7)
at Object.<anonymous> (/app/server.js:3:8)
at Module._compile (node:internal/modules/cjs/loader:1705:14)
at Object..js (node:internal/modules/cjs/loader:1838:10)
at Module.load (node:internal/modules/cjs/loader:1441:32)
at Function._load (node:internal/modules/cjs/loader:1263:12)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1976:8)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
code: 'EADDRINUSE',
errno: -98,
syscall: 'listen',
address: '0.0.0.0',
port: 3000
}
Node.js v22.22.2That's a real captured run, not a paraphrase. The only thing that changes across Node 20 → 21 → 22 is the internal CJS-loader frame numbers (the loader's line numbers shift release to release); the Error [code/errno/syscall/address/port] shape and the Server.setupListenHandle [as _listen2] frame are identical. I confirmed the same function (setupListenHandle, aliased to the legacy name _listen2) still exists, unchanged in behavior, in lib/net.js at the v24.9.0 and v26.0.0 tags on nodejs/node — this error's wording has been stable for years and isn't going anywhere.
Two details that matter and that most write-ups skip:
errno: -98is Linux-specific. Node'serr.errnoisn't a Node concept — it's libuv's negated OS errno. libuv'sinclude/uv/errno.hdefinesUV__EADDRINUSEas-(EADDRINUSE)on every POSIX platform, and the rawEADDRINUSEconstant differs by OS:98on Linux,48on macOS/BSD. So the same Node program reportserrno: -98on Linux anderrno: -48on macOS for the identical failure. Windows doesn't reuse the POSIX errno space at all — libuv hardcodesUV_EADDRINUSEto-4091there, mapped fromWSAEADDRINUSE(10048).err.code('EADDRINUSE') is the portable, cross-platform thing to check — never branch onerr.errno.- The message text itself does not vary by platform. Node builds it from
code,syscall, andaddress:portinlib/internal/errors.js, not from the OS's own strerror text, so "listen EADDRINUSE: address already in use 0.0.0.0:3000" is what you'll see whether you're on Linux, macOS, or Windows — only the invisibleerrnonumber differs.
If you're behind Express, Fastify, or any framework, the frames above sit underneath your framework's app.listen() call, but the top of the stack — code, errno, syscall: 'listen' — is identical, because every one of them eventually calls the same net.Server.prototype.listen.
How to Reproduce It
Node (ESM/CJS-agnostic, both work) — server.js:
javascript
const http = require('node:http');
const server = http.createServer((req, res) => res.end('ok'));
server.listen(3000, () => console.log('listening on 3000'));Run it, then run it again in a second terminal while the first is still up:
bash
node server.js
# listening on 3000
# in a second terminal, same directory:
node server.js
# Error: listen EADDRINUSE: address already in use 0.0.0.0:3000This needs nothing else — no package.json, no dependencies. It reproduces identically whether the second process is node server.js run by hand, a second docker run -p 3000:3000 container, or a watcher restarting your app before the old process has fully exited (see below).
The watch-mode variant — the one people actually hit in day-2 work. nodemon and Node's own --watch restart your process on file save by killing the old one and spawning a new one. If the old process's socket hasn't finished closing by the time the new one calls listen(), you get EADDRINUSE on a save you didn't expect to break anything:
bash
node --watch server.js
# edit and save the file while a request is in flight or right after boot
# Error: listen EADDRINUSE: address already in use 0.0.0.0:3000This isn't hypothetical — it's tracked upstream as nodejs/node#47990 ("--watch restarts without waiting for pending I/O") and nodejs/node#51954 (debouncing rapid restarts), and it's the same root cause behind years of nodemon reports like remy/nodemon#1893: the previous process is still in the process of releasing its socket (or, worse, its shutdown handler is doing async cleanup) when the next one binds.
The container variant. docker compose up with two services that both publish the same host port fails with a similar-looking but distinct error from the Docker daemon, not from Node — Bind for 0.0.0.0:3000 failed: port is already allocated. That's a Docker Engine error, one layer below anything Node's net module sees; if you see that exact string, the fix is in your docker-compose.yml port mappings, not your server.listen() call.
Version / Environment Behavior Matrix
This one is genuinely version-neutral: EADDRINUSE is a POSIX/Winsock bind-time errno that Node has surfaced the same way since the net module existed, and nothing about it changed across Node 20 (EOL 2026-04-30), 22 (Maintenance LTS, EOL 2027-04-30), 24 (Active LTS, EOL 2028-04-30), or 26 (Current since 2026-05-05). I confirmed the throw site is unchanged in lib/net.js at the 24.9.0 and 26.0.0 tags. Don't spend a version matrix on this — spend it on what's actually new around it:
| API / option | Added | What it changes |
|---|---|---|
server.listen({ exclusive }) | long-standing | With cluster, exclusive: false (the default under a cluster worker) shares one handle round-robin across workers instead of each worker binding its own socket — so workers don't collide on the same port. |
server.listen({ reusePort: true }) | v22.12.0 / v23.1.0 | Lets multiple independent sockets (even across separate processes, not just cluster workers) bind the same port on Linux 3.9+, FreeBSD 12+, DragonFlyBSD 3.6+, Solaris 11.4, AIX 7.2.5+; the OS load-balances incoming connections between them. This is a real alternative to cluster's handle-passing model for multi-process servers on the same port. It throws on platforms that don't support SO_REUSEPORT — filed as nodejs/node#61018 for the case where that failure terminates the process instead of surfacing cleanly. |
| Node release cadence | changes starting late 2026 | Node moves to one major per calendar year with an Alpha channel (27.0.0-alpha.x) replacing the old odd-release testing role; none of it touches net's bind/listen error path. |
What changes next: nothing planned for this error path specifically — check nodejs/node's lib/net.js history before assuming otherwise on a future major.
Why It Happens — Surface Level
Only one process can hold an exclusive bind on a given (address, port) pair at the transport layer. When your server calls .listen(port), the OS kernel tries to claim that socket; if another process (including a previous instance of your own app that hasn't fully exited) still holds it, the kernel refuses with EADDRINUSE, libuv translates that into UV_EADDRINUSE, and Node wraps it as the Error you see, emitted on the server's 'error' event — which, unhandled, is a fatal uncaught exception.
Why It Happens — Under the Hood
server.listen() bottoms out in lib/net.js's listenInCluster() → setupListenHandle() (still named _listen2 internally for backward compatibility — that's the frame you saw in the stack trace). Outside of cluster, this does a straightforward bind() + listen() against a new OS socket handle. The kernel's TCP stack keeps a table of bound (address, port) tuples; bind() fails at the syscall level if an entry already exists and neither socket set SO_REUSEADDR/SO_REUSEPORT. Node does not set SO_REUSEADDR on your behalf by default — that's a deliberate difference from some other runtimes, because reusing an address by default can let a new process silently steal traffic meant for a socket still finishing its shutdown (draining TIME_WAIT connections). That's exactly why reusePort had to be an explicit opt-in API rather than a default.
Two mechanisms make the same underlying kernel error surface differently depending on your setup:
cluster's handle-sharing. When you fork workers withcluster, the primary process usually owns the actual listening socket and hands duplicated handles to workers (exclusive: false, the default) — that'slistenInCluster()'s branch forcluster.isPrimary || exclusive. Workers cooperate instead of colliding because they're not each doing an independentbind().- The
TIME_WAITwindow. After a socket closes, the kernel can hold the port inTIME_WAITfor a short period to catch stray packets from the old connection. A process that crashed (rather than closing cleanly) or a watcher thatSIGKILLs the old process before it callsserver.close()leaves the port in exactly this state — which is why restarts undernodemon/--watchare the most common real-world trigger: the new process'sbind()races the old socket's actual release, not just the old process's exit.
The Fix
Quick fix — find and kill whatever's holding the port.
bash
# Linux / macOS
lsof -i :3000 -sTCP:LISTEN -t # prints the PID(s) holding the port
kill -9 $(lsof -i :3000 -sTCP:LISTEN -t)
# Linux, if lsof isn't installed
fuser -k 3000/tcp
# Windows (PowerShell / cmd)
netstat -ano | findstr :3000
taskkill /PID <pid_from_above> /FI ran lsof -i :3000 -sTCP:LISTEN -t against a live held port in this exact repro and it printed the correct PID — that's the command to reach for first, not a restart-in-a-loop.
Correct fix — handle 'error' on the server instead of crashing. Node's own docs show the shape; here it is extended into something you'd actually ship, with a bounded retry onto the next port rather than an infinite loop:
javascript
// server.js — CommonJS, Node 20+
const http = require('node:http');
const PORT = Number(process.env.PORT) || 3000;
function start(port, attemptsLeft = 5) {
const server = http.createServer((req, res) => res.end('ok'));
server.on('error', (err) => {
if (err.code === 'EADDRINUSE' && attemptsLeft > 0) {
console.error(`Port ${port} in use, retrying on ${port + 1}...`);
setTimeout(() => start(port + 1, attemptsLeft - 1), 200);
return;
}
console.error('Fatal server error:', err);
process.exit(1);
});
server.listen(port, () => {
console.log(`listening on ${server.address().port}`);
});
return server;
}
start(PORT);I ran this against a port already held by another instance — output was Port 3000 in use, retrying on 3001... followed by listening on 3001, captured live, not assumed. Use this pattern for local dev conveniences (auto-hop to a free port); don't ship silent port-hopping to production, where a fixed, known port is what your load balancer and health checks expect — there, let it fail loudly instead.
Fix the watch-mode race directly by making shutdown clean rather than abrupt, so the old process actually releases the socket before the watcher spawns the next one:
javascript
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
function shutdown() {
server.close(() => process.exit(0));
// if connections are keep-alive and won't drain, force it after a timeout
setTimeout(() => process.exit(1), 5000).unref();
}Docker Compose port collisions are fixed in the compose file, not the app: change one service's host-side mapping ("3001:3000" instead of "3000:3000"), or don't publish the port at all for services that only need to be reached over the compose network.
Best Practices & The Better Design
Don't hardcode the port at all where you can avoid it. For anything that spins up an ephemeral server — tests, CLIs, local tooling — bind to server.listen(0) and read the OS-assigned port back off server.address().port. That eliminates the entire class of "which port is free" bug, and it's what lets test suites run in parallel without ever colliding:
javascript
const server = http.createServer(handler).listen(0, () => {
const { port } = server.address();
console.log(`test server up on ${port}`);
});For long-running services, keep exactly one source of truth for the port — process.env.PORT, read once at startup, never a literal scattered across files — and fail fast and loudly (process.exit(1)) on a genuine EADDRINUSE in production rather than silently trying the next port. A production process quietly listening on a port nothing points to is a worse outage than a crash-and-restart your orchestrator can see and alert on.
Wire graceful shutdown (the SIGTERM handler above) into every long-running server regardless of whether you're using a watcher, because the same TIME_WAIT/socket-still-open mechanics that break nodemon restarts are what make docker stop / Kubernetes pod termination flaky without it — this is the same fix serving two different symptoms.
How to Prevent It Long-Term
- CI: run integration tests against
listen(0)ephemeral ports, never a fixed port, so parallel CI jobs on the same runner can't collide with each other or with a leftover process from a previous flaky run. - Process supervision: whatever restarts your process in production (systemd, PM2, Kubernetes) should already send
SIGTERMand wait beforeSIGKILL— check the actual grace period (Kubernetes defaultsterminationGracePeriodSecondsto 30) and make sure yourSIGTERMhandler finishes well inside it. - Watch mode: if
node --watch's restart races keep biting you, pin to the fix in nodejs/node#51954's resolution (debounced restarts) once it lands in the Node line you run, or fall back tonodemonwith an explicitdelaysetting as a stopgap. - Logging: log
err.codeanderr.address/err.portstructurally on any server-startup failure — don't let a startup crash get logged as a bare stack trace with no machine-readable field, sinceerr.code === 'EADDRINUSE'is exactly the kind of thing an on-call runbook should be able to match on automatically. - Monitoring: a restart-loop that never stabilizes (Kubernetes
CrashLoopBackOff, PM2's restart counter climbing) is frequently this error in disguise, misdiagnosed as an application bug — checkerr.codein the crash logs before assuming the code itself regressed.
Related, still-open topics worth linking once written: ECONNREFUSED / ECONNRESET / ETIMEDOUT cover the client-side networking failures that sit right next to this server-side one; server.close() hanging on keep-alive sockets is the deeper dive on the graceful-shutdown mechanics only sketched here.
Important
EADDRINUSEmeans the OS already has that(address, port)bound — by another process, a previous crashed instance, or a socket still draining inTIME_WAIT. It's not a Node bug.err.codeis portable;err.errnois not — it's libuv's negated OS errno, and it differs by platform (-98Linux,-48macOS,-4091Windows) for the identical failure.- The most common real-world trigger isn't "two servers running by accident" — it's
nodemon/node --watchrestarting before the old process's socket has actually released. lsof -i :PORT -sTCP:LISTEN -t(ornetstat -ano+taskkillon Windows) finds the culprit in one command; aserver.on('error', ...)handler with a bounded retry is the code-level fix.reusePort(Node 22.12.0+/23.1.0+) is a real, opt-in alternative toclusterfor sharing a port across processes — but it's platform-gated and still has rough edges on unsupported platforms.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.