Diagnosing "address already in use" and finding what's holding a port
A server won't start, and the error is some variant of EADDRINUSE — "address already in
use," "bind failed," or Node's Error: listen EADDRINUSE: address already in use. The fix
is always the same two steps: find out what's actually holding the port, then decide
whether to stop it or pick a different port. Here's how to do the first step correctly, and
why it sometimes shows nothing even though the error is real.
What's actually happening
Starting a TCP server on a port is a bind() system call, and only one process can bind a
given IP/port combination at a time (with one exception, SO_REUSEADDR, covered below). If
something else already holds that port, bind() fails with errno 98 on Linux
(EADDRINUSE) — confirmed directly: binding a socket, then attempting a second bind on the
same port from another process, fails with exactly errno: 98, EADDRINUSE, "Address already
in use". Whatever framework or runtime you're using surfaces that same underlying errno
under its own wrapper message.
Finding the culprit
ss is the modern, actively-maintained tool for this on Linux (netstat still works on
many systems but is deprecated upstream in favor of ss):
ss -ltnp | grep :8080
# LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("node",pid=41213,fd=3))
-l (listening sockets only), -t (TCP), -n (numeric ports, don't resolve service
names), -p (show the owning process — requires root or matching-user privileges to see
the PID for a process you don't own). The users: field gives you the exact PID and
process name.
lsof works the same way and is often more familiar on macOS, where it's the standard tool
(macOS's netstat/ss situation is different from Linux's):
lsof -i :8080
# COMMAND PID USER FD TYPE NODE NAME
# node 41213 jacks 3u IPv4 TCP *:8080 (LISTEN)
On Windows: netstat -ano | findstr :8080 gives the PID in the last column, then tasklist
/fi "PID eq <pid>" to see what process that actually is.
Why it sometimes shows nothing, and the port is still busy
If neither command shows anything holding the port but a new process still can't bind it,
the port is very likely in TIME_WAIT — a lingering, half-closed socket left over from a
connection that already ended. After a TCP connection closes, the side that initiated the
close holds the socket in TIME_WAIT for a fixed interval (commonly around 60 seconds,
platform-dependent) specifically to catch any stray packets still in flight from the old
connection. The process that owned it may already be completely gone — TIME_WAIT sockets
show up in ss -tan (without -l, since they're not listening sockets anymore) but
typically without an owning PID, because there usually isn't one anymore.
ss -tan | grep :8080
# TIME-WAIT 0 0 127.0.0.1:8080 127.0.0.1:52344
This is also exactly why a server that was just restarted sometimes fails to bind
immediately, then succeeds a minute later with no code change at all — the old socket
simply hadn't finished its TIME_WAIT window yet.
The fix, in order
- A real process is listed (via
ss -ltnporlsof -i): that's your answer — either stop that process, or configure your new one to use a different port. Killing a process you don't recognize just to free the port is worth pausing on first — check what it actually is before ending it. - Nothing's listed, but the bind still fails: check for a lingering
TIME_WAITsocket withss -tan(no-l). This resolves itself; waiting it out is the correct fix, not a workaround. - A server needs to restart immediately without waiting out
TIME_WAIT: this is whatSO_REUSEADDR(or equivalent — most frameworks expose this as a simple boolean option) exists for. It explicitly tells the kernel "let me bind this port even if a previous socket on it is still inTIME_WAIT" — the reason some servers restart instantly and others don't is almost always whether this flag is set.
Sources: Linux bind(2) / errno(3) man pages (EADDRINUSE = errno 98); ss(8) and
lsof(8) man pages; TCP TIME_WAIT behavior per RFC 9293 (TCP specification, §3.3.2);
SO_REUSEADDR behavior per socket(7). The errno 98 / ss/lsof output shown above was
reproduced directly, not illustrative.