You start your server and Node exits immediately with this (Node v22.23.2 on macOS; paths shortened):
node:events:497
throw er; // Unhandled 'error' event
^
Error: listen EADDRINUSE: address already in use :::3000
at Server.setupListenHandle [as _listen2] (node:net:1941:16)
at listenInCluster (node:net:1998:12)
at Server.listen (node:net:2103:7)
at Object.<anonymous> (/home/dev/shop-api/server.js:4:8)
Emitted 'error' event on Server instance at:
at emitErrorNT (node:net:1977:8)
at process.processTicksAndRejections (node:internal/process/task_queues:89:21) {
code: 'EADDRINUSE',
errno: -48,
syscall: 'listen',
address: '::',
port: 3000
}
Node.js v22.23.2The operating system refused to let your process listen on port 3000 because some other socket is already listening there. Usually that “other socket” is an older copy of your own server that never shut down.
Quick fix checklist
- Find who owns the port:
lsof -nP -iTCP:3000 -sTCP:LISTEN(macOS/Linux),ss -ltnp 'sport = :3000'(Linux) orGet-NetTCPConnection -LocalPort 3000 -State Listen(PowerShell). - If it is a stale copy of your app, stop it gracefully (
kill <pid>, Ctrl+C in its terminal, orStop-Process -Id <pid>), not with a blanket “kill all node” command. - If it is a different program, run yours elsewhere:
PORT=3001 npm run dev. - Check for a second terminal tab, a background
npm run dev, a debugger session or a Docker container publishing the same port. - Make the server close on
SIGINT/SIGTERMso restarts release the port. - In tests, listen on port
0and read the assigned port back.
Before you start
You need a terminal, a Node.js server that calls listen() (Express, Fastify, Nest and plain http all do), and permission to see process information for your own user. Commands below assume Node 22 or 24 LTS. On Linux, ss -p only shows process names for sockets your user owns unless you run it with elevated rights; that is fine for finding your own dev server.
Do not start by killing processes. A port can be held by a database, another team member’s service in a shared VM, or a system feature. Identify first, then decide.
Why it happens
A TCP server binds a socket to an address and port, then listens. The kernel allows only one listening socket per address and port combination (unless both opt into special sharing options, which Node does not use for ordinary servers). When your listen(3000) call reaches the kernel and the slot is taken, the syscall fails with EADDRINUSE and Node turns it into an 'error' event on the server. Because nothing handled that event, Node throws it, which is why you see Unhandled 'error' event at the top.
Reading the message precisely helps:
:::3000is the IPv6 unspecified address::followed by:3000. When you calllisten(port)without a host, Node listens on::, which on most systems accepts both IPv6 and IPv4 connections. It does not mean you configured IPv6.errnois platform specific:-48on macOS,-98on Linux. Thecode(EADDRINUSE) is the stable thing to match on.syscall: 'listen'tells you the failure happened when opening the server, not when handling a request.
The usual owners of the port:
- A previous run of your app. You closed the editor but the terminal is still running it, or a process manager kept it alive.
- A restart that left the old process running. Watchers such as nodemon and
node --watchsend a signal to the old process and start a fresh one. Installing a handler for a signal replaces Node’s default “exit” behaviour, so a handler that logs but never closes the server keeps the old copy alive. nodemon restarts withSIGUSR2by default, a signal some apps and libraries already use for log reopening or diagnostics. Process managers or scripts that signal only a wrapper (sh -c,npm run) instead of the real Node process cause the same leftover. - Something unrelated. Another project’s dev server, a container with
-p 3000:3000, or on recent macOS versions the AirPlay Receiver, which listens on ports 5000 and 7000.
Note
On macOS, a process listening on
127.0.0.1:3000does not always block another process from listening on::port 3000. In a test on macOS, the second server started without error and both were listening. Requests then reach whichever socket matches the address the client used, which looks like “my changes are not showing up” rather than a crash.lsofwill show both listeners.
Step-by-step walkthrough
Step 1: Reproduce it on purpose
Seeing the error on your own terms makes the rest of the process predictable. Save this as server.js:
const http = require('node:http');
const port = Number(process.env.PORT) || 3000;
const server = http.createServer((req, res) => res.end('ok'));
server.listen(port, () => console.log(`listening on ${port}`));Run it in one terminal with node server.js, then run the same command in a second terminal. The second one prints the error above and exits with status 1. Stop the first one with Ctrl+C before moving on.
Step 2: Find the process holding the port
On macOS or Linux:
lsof -nP -iTCP:3000 -sTCP:LISTENCOMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 5689 aditya 12u IPv6 0x8136d48330cfb8fa 0t0 TCP *:3000 (LISTEN)-nP skips DNS and port-name lookups (faster, and you see 3000 instead of a service name), -sTCP:LISTEN filters out client connections that merely talk to port 3000. On Linux without lsof, ss -ltnp 'sport = :3000' gives the same answer.
On Windows PowerShell:
Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object LocalAddress, OwningProcess
Get-Process -Id <OwningProcess>In cmd.exe, netstat -ano | findstr :3000 prints the PID in the last column.
Then see what that process is with ps -p <pid> -o pid,ppid,command (macOS/Linux). The parent PID (ppid) often reveals the culprit: a nodemon, a forgotten npm run dev, or a Docker proxy process.
Step 3: Stop it, or move your app
If it is your own stale server, ask it to stop with kill <pid> (SIGTERM) or Stop-Process -Id <pid>. Only escalate to kill -9 if it ignores SIGTERM, and treat that as a bug to fix in Step 4, because a server that ignores SIGTERM will also misbehave in production during deploys.
If the port belongs to something you need, change yours. Reading the port from the environment makes that a one-liner:
PORT=3001 node server.jsStep 4: Make shutdown release the port
Handle the 'error' event so the message is actionable, and close the server on signals so restart tools and orchestrators get the port back:
const http = require('node:http');
const port = Number(process.env.PORT ?? 3000);
const server = http.createServer((req, res) => res.end('ok'));
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Port ${port} is already in use. Stop the other process or set PORT.`);
process.exit(1);
}
throw err;
});
server.listen(port, () => console.log(`listening on http://localhost:${port}`));
let closing = false;
function shutdown(signal) {
if (closing) return;
closing = true;
console.log(`${signal}: closing server`);
server.close(() => process.exit(0));
server.closeIdleConnections();
setTimeout(() => process.exit(1), 5000).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);Three decisions matter here. server.close() stops accepting connections immediately, so the listening socket is released even while in-flight requests finish. closeIdleConnections() drops keep-alive connections that would otherwise keep close() waiting. The 5-second timer is a deadline, so a hung request cannot block the restart forever; unref() stops that timer from keeping the process alive on its own.
If you use nodemon, also handle the signal it restarts with (SIGUSR2), or tell it to use the one you already handle: nodemon --signal SIGTERM server.js.
Step 5: Use port 0 in tests
Hard-coded ports make parallel test runs collide with each other and with your dev server. Port 0 asks the OS for any free port:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
test('responds ok', async (t) => {
const server = http.createServer((req, res) => res.end('ok'));
server.listen(0, '127.0.0.1');
await once(server, 'listening');
t.after(() => server.close());
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/`);
assert.equal(await res.text(), 'ok');
});Export your app (or a createServer() function) separately from the file that calls listen(3000), so tests can start it on port 0.
Worked scenario
A team’s API runs with "dev": "nodemon src/index.js". Every second save, nodemon prints EADDRINUSE followed by [nodemon] app crashed - waiting for file changes before starting....
Diagnosis: lsof -nP -iTCP:3000 -sTCP:LISTEN shows a node process that started before the most recent restart. Searching the code for SIGUSR2 finds a handler added months earlier so operations could request a heap snapshot:
process.on('SIGUSR2', () => {
const file = v8.writeHeapSnapshot();
logger.info({ file }, 'heap snapshot written');
});nodemon restarts with SIGUSR2. Because a handler exists, Node no longer exits on that signal: the old server writes a heap snapshot, logs a line and keeps listening. nodemon starts the new copy, which cannot bind.
Fix: keep diagnostics on a signal the watcher does not use, and make the watcher send a signal the app treats as “shut down”.
{
"scripts": {
"dev": "nodemon --signal SIGTERM src/index.js"
}
}process.on('SIGTERM', () => {
logger.info('received SIGTERM, closing');
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5000).unref();
});Heap snapshots move to an admin-only endpoint. After the change, rapid saves restart cleanly and lsof shows exactly one listener.
Common mistake
The tempting fix is killall node, pkill node or taskkill /F /IM node.exe. It clears the error, but it also kills your editor’s language server, other projects, build watchers and anything else running on Node, and it hides the actual bug: something in your setup is not shutting the server down. The error will come back on the next restart.
A second mistake is auto-incrementing the port when 3000 is busy. It feels friendly, but your frontend proxy, OAuth callback URLs and teammates’ bookmarks all expect a fixed port, and you end up with two copies of the API running with different code. Fail loudly with a clear message instead (as in Step 4), and let the developer choose PORT.
Verify the behavior
- Start the server, then run
lsof -nP -iTCP:3000 -sTCP:LISTEN(or the PowerShell equivalent). Expect exactly one line. - Send
kill -TERM <pid>. ExpectSIGTERM: closing server, an exit status of 0, and thelsofcommand now printing nothing. - Start two copies; the second should print
Port 3000 is already in use. Stop the other process or set PORT.and exit with status 1 instead of a raw stack trace. - Run your test suite twice in parallel terminals. With port 0, neither run fails with EADDRINUSE.
Interview exercise
“Your Node service fails during a rolling deploy with EADDRINUSE on the new pod’s first start, but it never happens locally. What would you check?”
Answer and reasoning
A fresh pod has no leftover processes, so something in the pod’s own setup is opening the port twice. I would check three things. First, containers in one pod share a network namespace, so a sidecar (a metrics agent, a proxy) listening on the same port collides even though each image works alone. Second, the entrypoint may start the app twice, for example a shell script that runs npm start & and then the image’s CMD also runs node server.js, or a process manager in fork mode configured to start several instances on one fixed port (the cluster module avoids this by letting workers share the primary’s listening socket). Third, hostNetwork: true puts the pod on the node’s network, so two replicas scheduled on the same node, or a node daemon, fight over the port. To confirm, I would run ss -ltnp inside the pod to see which process already holds the port. The fixes follow from the cause: give the sidecar a different port, make the entrypoint exec node server.js so there is exactly one server process (which also lets SIGTERM reach Node during deploys), and keep the port configurable through PORT. EADDRINUSE is a symptom of a process ownership problem, so the answer is about who owns the socket, not about picking another number.
Continue learning
- Node.js interview questions and Node.js MCQs
- Node.js graceful shutdown and in-flight requests
- Node.js environment variables and configuration
- Error: connect ECONNREFUSED in Node.js, the client-side counterpart
- Official reference: Node.js common system errors and net.Server listen()