Projects / Open Source Package

Open Source Package

port-killer

`EADDRINUSE`. Every developer knows that error by sight before they know what it stands for.

EADDRINUSE. Every developer knows that error by sight before they know what it stands for. Your dev server crashed, or you closed the terminal without stopping it properly, and now port 3000 belongs to a process that no longer has a window you can see, and you're stuck doing the little ritual: find the PID, kill the PID, hope you found the right one.

On Windows that ritual is netstat -ano | findstr :3000, squinting at a column of numbers to find the PID, then taskkill /PID <that number> /F. On Mac or Linux it's lsof -ti tcp:3000 piped into kill -9. Different commands, same annoyance, and different enough that muscle memory from one platform doesn't transfer to the other. I got tired of looking this up every time I switched contexts, so port-killer exists to collapse it into one command that works the same way regardless of which machine I'm on.

What it actually does

port-killer kill 3000 finds whatever's listening on port 3000 and force-kills it. port-killer find 3000 tells you what's there without touching it, in case you want to check before you commit to murder. There's a short alias, pk, because if I'm going to reach for this command constantly I don't want to type the whole word every time. And there's a bare-argument shortcut: npx port-killer 3000 with no subcommand at all defaults straight to kill mode, because most of the time I already know I want it dead and typing kill first is one more word than I need.

Under the hood it branches on platform. Windows path parses netstat -ano output filtered through findstr, then cross-references tasklist to get a human-readable process name instead of just a bare PID. Unix path uses lsof -ti tcp:<port> -sTCP:LISTEN for the PID directly, then ps for the name. Two genuinely different code paths, held together by one consistent interface on top, which is really the entire value proposition of the tool: I don't want to remember which flavor of command works on which machine. I want to type the same thing everywhere and have it work.

killAll() handles multiple ports in one call, running the kills in parallel via Promise.all rather than one at a time. Small detail, but if you've got three stale dev servers hanging around from an afternoon of context-switching between projects, waiting for three sequential kills instead of one parallel batch is the kind of friction that shouldn't exist in a tool whose entire purpose is removing friction.

No safety net, and that's a choice worth questioning

There's no confirmation prompt before it force-kills anything. You type the command, it finds the process, it kills it. No "are you sure," no dry-run flag, nothing. I built it that way because the entire point of the tool is speed: if I wanted to think carefully about whether to kill a process, I'd use the manual netstat/kill dance and read the output first. But I'll say plainly that this is a real sharp edge, not a hidden one. If you fat-finger the wrong port number and something important happens to be listening there, port-killer will kill it just as fast and just as silently as it kills your abandoned dev server. I've never actually hit that scenario myself (the ports I check are always ones I expect to be mine), but "I've personally gotten away with it" isn't the same as "it's safe," and I know the difference.

The error handling matches the same philosophy: shell-outs are wrapped in a blanket try/catch that returns empty results on failure rather than surfacing what went wrong. Fine for a tool you run interactively and can eyeball the result of immediately. Not fine if you were scripting this into something unattended. It isn't built for that, and I haven't pretended otherwise anywhere in the README.

Where the README and the code line up

Unlike a couple of the other small tools in this batch, port-killer's docs actually describe the real exports: kill(), find(), killAll() match what's in src/index.ts. I got this one right the first time, probably because the tool is small enough and the mental model simple enough that there wasn't much room for the description to drift from the implementation while I was writing it. Small surface area is its own kind of correctness guarantee. The bigger and more ambitious a tool's promises get, the more likely the docs and the code quietly stop agreeing with each other, which is exactly what happened with next-on-windows, and exactly what didn't happen here.

Why it's worth having at all

This is maybe the smallest, least impressive tool in the whole batch, and I think that's fine to say outright. It doesn't solve an interesting problem. It automates an annoying, mechanical, ten-second task that I was doing manually several times a week across every project I had open. The value isn't cleverness. It's that ten seconds saved fifteen times a day, across months, adds up to real hours. More than the raw time, it removes a specific little spike of irritation that used to interrupt whatever I was actually trying to think about. Good tooling is often like this: not glamorous, just quietly out of your way.

The bare-argument decision, second-guessed and kept

I went back and forth on whether npx port-killer 3000 with no subcommand should default to kill or to find. Defaulting to find is the more cautious choice: show me what's there before I commit to anything destructive. I chose kill anyway, and the reasoning is almost entirely about who actually reaches for this tool in the moment: by the time I'm typing port-killer into a terminal, I've already looked at the failed dev server, already know port 3000 is the problem, and already know I want it gone. Making the fast path the cautious one would optimize for a use case ("I'm not sure what's on this port") that's much rarer in practice than "I know exactly what's on this port and I want it dead in one command." A tool built to remove friction shouldn't quietly reintroduce friction in its most common path just to feel safer on paper.

That said, find still exists as an explicit, separate command for the actual rare case where I do want to check first, usually on a machine I don't use daily, where I'm less sure what's actually running. The option to be careful is there. It's just not the default, because defaults should match the common case, not the cautious one.

What surprised me building the Windows branch

I expected the Unix side to be the easy one and Windows to be the fight, since that's usually how cross-platform tooling goes for me. It went the other way here. lsof -ti tcp:<port> -sTCP:LISTEN on Unix gives you exactly the PID you want, cleanly, in one call, no parsing required beyond splitting on newlines. The Windows side needed two separate commands chained together, because netstat -ano gives you a PID but not a process name, and findstr is doing text matching on netstat's output format, which is whitespace-column-aligned in a way that's mildly annoying to parse reliably. Getting a human-readable name required a second round trip through tasklist and matching PIDs across both outputs. It works, and it's been reliable, but it's a genuinely more fragile pipeline than the four-word Unix equivalent, which is a small, honest reminder of how much easier Unix tooling makes this exact category of problem compared to Windows' text-based command output.

Full stack developer. Founder of Yashveer Labs. pk 3000, and back to work.

Start a conversation about this.

Whether it's port-killer itself or the next system worth building, the lab is reachable.