Skip to content

Error: listen EADDRINUSE — Causes and Fixes

Node's 'listen EADDRINUSE: address already in use' means something already owns that port. Here's why, and the exact fixes for dev, watch mode, and Docker.

nodejs javascript-errors eaddrinuse libuv express docker nodejs-24 nodejs-26
Bharath G
Reading Progress

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.2

That'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: -98 is Linux-specific. Node's err.errno isn't a Node concept — it's libuv's negated OS errno. libuv's include/uv/errno.h defines UV__EADDRINUSE as -(EADDRINUSE) on every POSIX platform, and the raw EADDRINUSE constant differs by OS: 98 on Linux, 48 on macOS/BSD. So the same Node program reports errno: -98 on Linux and errno: -48 on macOS for the identical failure. Windows doesn't reuse the POSIX errno space at all — libuv hardcodes UV_EADDRINUSE to -4091 there, mapped from WSAEADDRINUSE (10048). err.code ('EADDRINUSE') is the portable, cross-platform thing to check — never branch on err.errno.
  • The message text itself does not vary by platform. Node builds it from code, syscall, and address:port in lib/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 invisible errno number 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:3000

This 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:3000

This 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 / optionAddedWhat it changes
server.listen({ exclusive })long-standingWith 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.0Lets 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 cadencechanges starting late 2026Node 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 with cluster, the primary process usually owns the actual listening socket and hands duplicated handles to workers (exclusive: false, the default) — that's listenInCluster()'s branch for cluster.isPrimary || exclusive. Workers cooperate instead of colliding because they're not each doing an independent bind().
  • The TIME_WAIT window. After a socket closes, the kernel can hold the port in TIME_WAIT for a short period to catch stray packets from the old connection. A process that crashed (rather than closing cleanly) or a watcher that SIGKILLs the old process before it calls server.close() leaves the port in exactly this state — which is why restarts under nodemon/--watch are the most common real-world trigger: the new process's bind() 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> /F

I 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 SIGTERM and wait before SIGKILL — check the actual grace period (Kubernetes defaults terminationGracePeriodSeconds to 30) and make sure your SIGTERM handler 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 to nodemon with an explicit delay setting as a stopgap.
  • Logging: log err.code and err.address/err.port structurally on any server-startup failure — don't let a startup crash get logged as a bare stack trace with no machine-readable field, since err.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 — check err.code in 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

  • EADDRINUSE means the OS already has that (address, port) bound — by another process, a previous crashed instance, or a socket still draining in TIME_WAIT. It's not a Node bug.
  • err.code is portable; err.errno is not — it's libuv's negated OS errno, and it differs by platform (-98 Linux, -48 macOS, -4091 Windows) for the identical failure.
  • The most common real-world trigger isn't "two servers running by accident" — it's nodemon/node --watch restarting before the old process's socket has actually released.
  • lsof -i :PORT -sTCP:LISTEN -t (or netstat -ano + taskkill on Windows) finds the culprit in one command; a server.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 to cluster for sharing a port across processes — but it's platform-gated and still has rough edges on unsupported platforms.
nodejsjavascript-errorseaddrinuselibuvexpressdockernodejs-24nodejs-26

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments