If you write Node, you've seen this:
Error: listen EADDRINUSE: address already in use :::3000
and you've probably fixed it with npx kill-port 3000. kill-port is downloaded about 2.1 million times a week, and plenty of projects run it in predev and pretest hooks.
I read through kill-port's issue tracker and the source of the released version, 2.0.1, and one line made me stop. So I tested it on Windows 11: one server on port 4471, an unrelated one on 44710, then:
> npx kill-port@2.0.1 4471
Process on port 4471 killed
Both servers were dead. Then I ran it again, with nothing at all on 4471:
> npx kill-port@2.0.1 4471
Process on port 4471 killed
Exit code 0 both times. I got the same false "killed" message on Ubuntu 22.04. Here's why, and what I built instead.
What kill-port 2.0.1 actually does
On Windows, it runs netstat -nao and keeps lines that match this regular expression:
new RegExp(`^ *TCP *[^ ]*:${port}`, 'gm')
There's no end anchor, so :3000 also matches :30001, :30002 and so on. Ask it to free 3000 and it will also TaskKill /F whatever is listening on 30001.
On macOS and Linux, it first runs lsof -i -P, which lists every socket on the machine and resolves every address through DNS. That is the slowness people report in issue #61. Then it decides whether anything is on the port with:
lines.filter((line) => line.match(new RegExp(`:*${port}`)))
That matches "3000" anywhere in the line, including a PID like 13000. And if lsof prints nothing at all (no sockets it can see, as on my Ubuntu machine), the check is skipped and the call resolves as if it succeeded. Either way, the CLI only reports failure when the promise rejects, so you get Process on port 3000 killed even though nothing was killed.
A few more things you hit in scripts and CI:
- It needs
lsof, which Alpine, distroless and many slim images don't have (#9, #45). - When the port is already free, it reports an error (#87). When a kill really fails, the CLI still exits 0.
- It always sends
SIGKILL; there's no way to let a server shut down cleanly (#48). - It runs its commands through a shell and has two runtime dependencies. In November 2025, the Shai-Hulud worm published infected kill-port versions 2.0.2 and 2.0.3. npm removed them within about 12 hours, but anyone who installed them in that window had to treat the machine as compromised.
To be fair to kill-port: its maintainer has been active again since early October 2026, and the master branch now has exact port matching, --quiet, signal choice and bundled types. None of that is released yet, and it still needs lsof.
What I built: kill-port-safe
npx kill-port-safe 3000
It keeps kill-port's CLI and API, so switching is a one-line change, and it also installs a kill-port command so existing scripts keep working. The interesting parts are in how it finds the process.
Linux: no lsof, just /proc
The kernel already publishes every socket in /proc/net/tcp and /proc/net/tcp6. Each row has the local address in hex, the socket state (0A is LISTEN) and an inode number:
sl local_address rem_address st ... inode
0: 00000000:0BB8 00000000:0000 0A ... 123456
0BB8 is 3000. To find the owning process, look at every /proc/<pid>/fd/* symlink. The one pointing at socket:[123456] belongs to the process holding the port. That's what lsof and fuser do internally, and it works in any container that has /proc, which is all of them. If the socket exists but no readable fd points at it, the port belongs to another user (usually root), and kill-port-safe says so instead of failing silently.
Windows: don't read the State column
netstat -nao prints LISTENING on English Windows, ABHÖREN on German Windows and something else again on other languages. Instead of reading the state, kill-port-safe treats a TCP row as a listener when its foreign address is 0.0.0.0:0 or [::]:0. It also reads the full table, not netstat -p TCP, because that filter hides IPv6-only listeners like ::1, which is where some dev servers bind.
macOS: lsof, but only for the ports you asked about
lsof -nP -iTCP:3000 -sTCP:LISTEN -Fpn: no DNS lookups, no full socket scan, listeners only, and a machine-readable output format that doesn't change between macOS releases.
And the rest
- Exact port matching; only processes listening on the port are touched, never ones merely connected to it.
- It tells you what it killed, and
who 3000shows what's on a port without killing anything. - It exits 0 when the port is already free, so
predevhooks don't fail on a clean machine, and 1 when a kill really fails. -
--gracefulsendsSIGTERMand only force-kills if the process is still there after 5 seconds. - It waits until the port is actually free before returning, so your next command doesn't race the dying process.
- It never runs a shell, and refuses to kill itself, its parent process or the Windows System process.
- Zero dependencies, no install scripts, published from GitHub Actions with npm provenance.
Is it faster?
Honestly, it depends on the OS. Median of 10 runs on GitHub Actions runners, Node 24, from a fresh listener to a free port (run, script):
| kill-port-safe | kill-port 2.0.1 | |
|---|---|---|
| Linux | 48 ms | 90 ms |
| macOS | 129 ms | 134 ms |
| Windows | 255 ms | 215 ms |
On Windows it's slower because it looks up the process name so it can tell you what it killed. On a busy machine with thousands of open connections, the gap on macOS and Linux grows, because kill-port 2.0.1 lists and resolves all of them. But speed isn't why you'd switch.
Other options
-
kill-port-process is zero-dependency too and actively maintained. It needs Node 22+, uses
lsoforsson Linux, and its API differs from kill-port's. - fkill is a general process killer (by PID, name or port) with a nice interactive CLI. Use it if that's what you want.
Try it
npx kill-port-safe who 3000
npx kill-port-safe 3000
Repo: https://github.com/CodeRafay/kill-port-safe
If it fails on your machine, please open an issue with the output of npx kill-port-safe who <port> --json. Odd platforms and localized Windows installs are exactly the reports I want.
Thanks to Tiaan du Plessis for kill-port and to ceifa for kill-port-fast; the port lookup builds on their work.
Top comments (1)
The unanchored regex matching :30001 when you ask for 3000 is a great catch, and the false "killed" message with exit code 0 would bite a lot of CI scripts. Reading /proc/net/tcp and matching socket inodes instead of shelling out to lsof is a neat way to make it work in slim containers. Does it handle several processes sharing one port with SO_REUSEPORT?