Skip to main content

Killing a Process on a Port, Properly

What a stuck port actually is, why TIME_WAIT and CLOSE_WAIT happen, and how to free the port without leaving a mess behind your app.

NKNabin Khair
16 min read
Cover image for Killing a Process on a Port, Properly

You run npm run dev and the terminal yells back: "Port 3000 is already in use." Most posts on the internet tell you to run kill -9 and move on. That works often enough that nobody questions it, and it is also the reason you sometimes lose unsaved data, leave half-written log files, or end up with a port that still will not free even after you have killed everything you can find.

This article is the version of "how to free a port" that I wish I had read earlier. It explains what is actually happening when a port is stuck, why kill -9 is the wrong first move, what TIME_WAIT and CLOSE_WAIT actually mean, and how to handle the modern causes — Docker, SSH tunnels, macOS background services — that the average tutorial does not mention. There is a quick reference at the end if you just want the commands.

What "the port is in use" actually means

A TCP port is not a thing the OS owns. It is a number that, combined with an IP address and a protocol, identifies one end of a socket. When a process calls bind(2) on a socket and then listen(2), the kernel records the tuple (protocol, address, port) as belonging to that socket's file descriptor in that process. While the file descriptor is open in any process, no other socket can bind() the same tuple. A second bind() returns EADDRINUSE — the kernel-level error your dev server prints in human-readable form.

The port becomes free again when every file descriptor referring to that listening socket is closed. That last sentence is more important than it looks. If your Node server forks a worker that inherits the listen socket, the port stays bound until both the parent and the worker exit. If you Ctrl+C the parent and the worker keeps running, the port is still busy and you will be confused about why. This is the orphan-worker scenario, and it is one of the more common reasons "I killed it but the port is still stuck."

The other reason is more subtle: even when no process holds the socket, the kernel itself can keep the port reserved for a short time. To understand why, you have to know a few of the TCP states.

The three states that bite developers

RFC 9293, the current TCP specification, defines eleven states a connection can move through. Most of them you will never see. Three of them are worth knowing because they map directly to "why is my port stuck."

LISTEN is the obvious one. A process called bind() and listen() and is currently accepting connections. The fix is to stop the process.

TIME_WAIT is where the kernel is being careful on your behalf. RFC 9293 describes it as "waiting for enough time to pass to be sure the remote TCP peer received the acknowledgment of its connection termination request and to avoid new connections being impacted by delayed segments from previous connections." Translated: when one side of a TCP connection closes first, its socket sits in TIME_WAIT long enough that any straggling packets from the old connection cannot accidentally get delivered to a brand-new connection that happened to reuse the same port. The connection is over from your application's point of view, but the kernel still owns the tuple.

The duration is not what most people think. The RFC says 2 × MSL (Maximum Segment Lifetime) and recommends an MSL of 2 minutes, so the spec maximum is 4 minutes. Real systems pick shorter values:

  • Linux: 60 seconds. Hardcoded in include/net/tcp.h as TCP_TIMEWAIT_LEN (60*HZ). There is no sysctl to tune it — you would have to recompile the kernel.
  • macOS / BSD: about 30 seconds. The MSL constant is 15 seconds (TCPTV_MSL in Darwin's bsd/netinet/tcp_timer.h); confirmable at runtime with sysctl net.inet.tcp.msl.
  • Windows: typically 120 seconds on modern Windows (varies by version and stack). Tunable via the registry value HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay, range 2-300.

If you stop a server, restart it immediately, and see "address already in use" even though lsof shows no listener — first rerun with sudo and -sTCP:LISTEN to rule out a process you cannot see. If there is still no listener, TIME_WAIT on old connection sockets is a likely cause. The fix is not to kill anything. The fix is SO_REUSEADDR on the listening socket before bind(), which we will get to.

There is one nuance worth flagging: TIME_WAIT lands on whichever side called close() first on a connection socket — not on the listening socket itself. When your dev server shuts down, the listen socket is closed and the port is released unless another process (or a child worker) still holds it. What blocks an immediate restart is usually old connection sockets sitting in TIME_WAIT on the same local address and port tuple. Without SO_REUSEADDR, the kernel may refuse a new bind() until those entries expire. If a client closed first (a browser tab, a curl exit), TIME_WAIT sits on the client side and your server's listen port is unaffected. This is why TIME_WAIT can block restarts after a crash or Ctrl+C but often does not matter during normal steady-state operation.

CLOSE_WAIT is the most interesting state, because it almost always means your app has a bug. RFC 9293 describes it as "waiting for a connection termination request from the local user." The remote side has sent FIN, the kernel has acknowledged it, and it is now waiting for your application to call close(). There is no kernel timeout for CLOSE_WAIT. The connection socket stays open — consuming a file descriptor — until your app exits or finally calls close(). It does not usually block your listen port from accepting new connections, but a pile of CLOSE_WAIT sockets is a leak you need to fix.

If you see many CLOSE_WAIT entries piling up in ss -tan state close-wait, it means somewhere in your code there is a code path — usually an unhandled exception, a missing try/finally, or a forgotten close() in a long-running worker — that opens connections and never tears them down properly. Restarting the process clears it. Fixing the bug is the real solution.

Finding what holds the port

Before you kill anything, find out what owns the port. Killing blind is how you accidentally take down a database or a system service.

On Linux, the modern tool is ss, not netstat. ss reads from the kernel's NETLINK_INET_DIAG socket-diagnostic interface, while netstat parses /proc/net/tcp line by line — ss is faster and has better state filtering.

sudo ss -tlnp sport = :3000

The flags: -t for TCP, -l for listening sockets only, -n for numeric output (skip DNS and /etc/services lookups), -p to show the owning process (requires sudo when the listener is owned by another user). sport = :3000 filters to local port 3000 without the false positives you get from grep :3000 matching :30000. You will get output like users:(("node",pid=12345,fd=20)).

lsof works on Linux too, but it is often not pre-installed (apt install lsof, dnf install lsof). When you have it:

sudo lsof -nP -iTCP:3000 -sTCP:LISTEN

On macOS, there is no ss. Use lsof:

lsof -nP -iTCP:3000 -sTCP:LISTEN

-n skips DNS, -P skips port-name translation, -iTCP:3000 filters to TCP port 3000, -sTCP:LISTEN filters to listening sockets only. This is more precise than the bare lsof -i :3000 you see in most tutorials, which prints everything including outbound connections from your browser.

A useful caveat: by default lsof shows only files visible to your user. If the port is held by a system process owned by root, you will see nothing and assume the port is free. Run with sudo when in doubt:

sudo lsof -nP -iTCP:3000 -sTCP:LISTEN

On Windows, the PowerShell-native form is cleaner than netstat:

Get-NetTCPConnection -LocalPort 3000 -State Listen | Select-Object OwningProcess, State
Get-Process -Id (Get-NetTCPConnection -LocalPort 3000 -State Listen).OwningProcess

Filter with -State Listen so you do not pick up outbound client connections that happen to use the same local port. If nothing is listening, the second command will error — that is your answer.

The classic netstat -ano | findstr :3000 still works. Add -b to see executable names, but that requires Administrator privileges.

Killing the process, properly

This is the part where most tutorials get it wrong.

When you run kill <PID> with no signal, the kernel sends SIGTERM (signal 15). When you run kill -9 <PID>, it sends SIGKILL (signal 9). The two are not equivalent.

SIGTERM is a polite request. The process can install a handler with sigaction(2), run cleanup code, flush buffers, close database connections, write a final log line, and then exit cleanly. Most well-written servers do exactly this — Node's process.on('SIGTERM'), Python's signal handlers, Go's signal.Notify, all hook into SIGTERM.

SIGKILL cannot be caught, blocked, or ignored. The Linux signal(7) man page is unambiguous: "The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored." The kernel terminates the process directly, without ever scheduling its userland code again. No atexit handlers run. No destructors fire. No buffered writes flush. No graceful TCP close. The kernel will close the file descriptors as part of process teardown, which means open client connections get an RST instead of a clean FIN — clients see "connection reset by peer" rather than a normal disconnect.

For a dev server with no in-flight work, none of this matters. For anything writing to a database, holding a file lock, or maintaining state that needs to be flushed, kill -9 first is a way to lose data.

The conventional escalation, used by systemd (default TimeoutStopSec=90s) and Docker (docker stop --time=10), is:

  1. kill <PID> — sends SIGTERM.
  2. Wait 5-10 seconds.
  3. If still alive, kill -9 <PID> — sends SIGKILL.

I keep this as a small shell function:

killport() {
  local port="$1"
  local pids
  pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null)
  if [ -z "$pids" ]; then
    echo "Nothing listening on port $port"
    return
  fi
  kill $pids 2>/dev/null
  for i in 1 2 3 4 5; do
    sleep 1
    pids=$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null)
    [ -z "$pids" ] && { echo "Port $port freed"; return; }
  done
  echo "Process(es) $pids did not respond to SIGTERM, sending SIGKILL"
  kill -9 $pids 2>/dev/null
}

This is the version I wish more dotfiles repos shipped. It tries the right thing first, gives the process a few seconds to clean up, and only escalates when it has to.

On Linux, fuser -k 3000/tcp is a one-shot equivalent. This is the fuser from the psmisc package — it is not the same as the minimal POSIX fuser on macOS, which does not support the PORT/tcp syntax. The default signal fuser -k sends is SIGKILL, so for the same reason as above, prefer fuser -k -TERM 3000/tcp first if you care about clean shutdown.

When the holder is not your dev server

Half the time I have spent fighting "port already in use" was not because of a stuck Node process. It was something less obvious. These are the modern causes worth knowing.

Docker. When you docker run -p 3000:3000, Docker adds iptables DNAT rules in the DOCKER chain and, by default, spawns a userland helper called docker-proxy that binds to the host port. The proxy exists mainly to handle loopback (127.0.0.1) hairpin routing — without it, curl localhost:3000 from the host may not reach the container depending on your setup. You can disable it with "userland-proxy": false in /etc/docker/daemon.json, in which case only iptables handles forwarding. The practical consequence: lsof -nP -iTCP:3000 -sTCP:LISTEN usually shows docker-proxy as the listener on Linux, not your app. On Docker Desktop (macOS, Windows), the listener appears as com.docker.backend or a similar Desktop process. If docker ps shows a running container with the port mapped, that is your culprit. Stop the container with docker stop, not kill. Stopped containers (visible only in docker ps -a) do not hold the port.

SSH local port forwarding. ssh -L 3000:localhost:3000 user@host opens a local listener on port 3000 that tunnels to the remote host. If you backgrounded the SSH session with -f, ran it inside a tmux pane, or just forgot about a terminal, the local port stays bound until the SSH client exits. lsof -nP -iTCP:3000 -sTCP:LISTEN will show COMMAND as ssh — if that ever surprises you, this is why.

macOS background services. Since macOS Monterey (12), AirPlay Receiver listens on port 5000 and port 7000 by default. This collides with anything trying to use 5000 — Flask's default, plenty of dev servers. The fix is one toggle: on Ventura and later, System Settings → General → AirDrop & Handoff → turn off "AirPlay Receiver"; on Monterey, System Preferences → Sharing → uncheck "AirPlay Receiver". The owning process is usually ControlCenter. You will not be able to kill it and have it stay dead — launchd will respawn it. Toggle the setting instead.

launchd auto-respawn. macOS's PID 1 is launchd, and any service with KeepAlive=true in its plist gets restarted within seconds of being killed. If you kill something and the port reappears within ten seconds, look in launchctl list and launchctl print for the offender. On modern macOS, use launchctl bootout gui/$(id -u)/<service-label> rather than the deprecated launchctl unload.

Privileged ports. Ports below 1024 require root on Unix. This is enforced by the kernel — Linux's ip(7) man page: "a privileged process (on Linux: a process that has the CAP_NET_BIND_SERVICE capability in the user namespace governing its network namespace) may bind(2) to these sockets." If your app is trying to bind port 80 or 443 and getting EACCES, the port is probably not in use — you just lack the capability to bind below 1024. The two clean fixes are running behind a reverse proxy (nginx, Caddy) that has the capability and forwarding to a high port your app owns, or granting the capability to the binary directly:

sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/node

The setcap approach has caveats — you have to reapply it after every binary update, and giving the capability to an interpreter (Node, Python) effectively gives it to every script you run with that interpreter. The reverse-proxy approach is what production systems use for a reason.

WSL2. On Windows 11 22H2 and later, WSL2 supports mirrored networking mode, where the WSL2 VM shares the host's network stack and localhost works transparently in both directions. Add this to .wslconfig:

[wsl2]
networkingMode=mirrored

After editing .wslconfig, run wsl --shutdown from PowerShell and restart your distro — WSL only reads the file on startup.

In the older NAT mode, ports bound inside WSL2 are not automatically reachable from Windows; people end up with stale netsh interface portproxy rules they forgot they added. If a port behaves strangely on WSL2, check netsh interface portproxy show all from an Administrator PowerShell.

The fix when you control the server: SO_REUSEADDR

If you are restarting your own server and TIME_WAIT is what is blocking the rebind, the right fix is not killing anything — it is setting SO_REUSEADDR on the listening socket before bind(). From socket(7): "SO_REUSEADDR indicates that the rules used in validating addresses supplied in a bind(2) call should allow reuse of local addresses." Specifically on Linux, it allows binding when a previous socket for the same (addr, port) tuple is in TIME_WAIT. It does not let you steal a port from a process that is still actively listening — that protection stays in place.

In Node.js, every net.Socket gets SO_REUSEADDR unconditionally — you cannot turn it off from JavaScript. That is separate from the exclusive listen option, which only controls whether cluster workers share the same underlying socket handle. In Python's standard library, you have to set it explicitly:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', 3000))

If you find yourself running "kill the port" between every restart of a Python (or other non-Node) server you wrote, the SO_REUSEADDR line above is usually what you are missing. Node.js already sets it for you.

A separate option, SO_REUSEPORT (Linux 3.9+), permits multiple sockets to bind to the same address and port simultaneously, with the kernel load-balancing across them. That is for production multi-process servers, not for fixing dev-loop annoyances.

Tools to keep around

A few small utilities are worth installing for moments when the OS native ones feel clunky.

npx kill-port 3000 is convenient if you live inside a Node toolchain and do not want to remember which platform-specific command to use. It shells out to lsof/netstat under the hood and sends SIGKILL, so know what it is doing before you run it on something stateful.

fuser -k 3000/tcp on Linux is the cleanest one-liner for the simple case (sends SIGKILL by default).

htop and procs are nicer for seeing the process tree (parent/child relationships matter when an orphan worker is holding a socket).

For Windows, Process Explorer from Sysinternals shows handles and can find which process holds a TCP port without leaving the GUI.

Quick reference

GoalLinuxmacOSWindows
Find listener on a portsudo ss -tlnp sport = :PORTlsof -nP -iTCP:PORT -sTCP:LISTENGet-NetTCPConnection -LocalPort PORT -State Listen
Find any process touching a portsudo ss -tnp sport = :PORTlsof -nP -i :PORTnetstat -ano | findstr :PORT
Polite kill (SIGTERM)kill PIDkill PIDStop-Process -Id PID
Force kill (SIGKILL)kill -9 PIDkill -9 PIDStop-Process -Id PID -Force
One-shot port kill (SIGKILL)fuser -k PORT/tcpkill -9 $(lsof -tiTCP:PORT -sTCP:LISTEN)Stop-Process -Id (Get-NetTCPConnection -LocalPort PORT -State Listen).OwningProcess -Force
Polite one-shot port killfuser -k -TERM PORT/tcpkill $(lsof -tiTCP:PORT -sTCP:LISTEN)Stop-Process -Id (Get-NetTCPConnection -LocalPort PORT -State Listen).OwningProcess
See TCP statesss -tannetstat -anvp tcp (states only; use lsof for PIDs)Get-NetTCPConnection
Check what Docker holdsdocker psdocker psdocker ps
Cross-platform shortcutnpx kill-port PORTnpx kill-port PORTnpx kill-port PORT

What to remember

The port is held by a file descriptor somewhere — find that first, then decide what to do.

SIGTERM first, SIGKILL only when the process refuses to leave. Five seconds is plenty.

TIME_WAIT is the kernel doing its job. The fix is SO_REUSEADDR, not killing anything.

CLOSE_WAIT accumulating is your code forgetting to call close(). Restarting masks it; you still have a leak.

Modern "stuck port" causes are usually Docker, SSH tunnels, AirPlay on macOS, or a worker process you forgot about. Look there before you reach for the hammer.

A port that will not free is almost always a question worth answering, not a process worth killing harder.