Every developer has some version of this, several times a week:
$ npm run dev
Error: listen EADDRINUSE: address already in use :::3000
What follows is a small ritual: lsof -i :3000, squint at the columns,
copy a PID, kill -9 it, hope it wasn't something else. And if that
doesn't fix it, there's a second, more confusing question underneath —
is something actually listening? Is this a leftover Docker container? A
zombie from a crashed process? Or is the port stuck in some kernel state
that has nothing to do with a "real" conflict at all?
The tools that are supposed to answer this were built for sysadmins auditing a server, not developers iterating on a laptop:
| Tool | Good at | Falls short on |
|---|---|---|
lsof |
Exhaustive, authoritative | Cryptic flags, no color, not on Windows, slow -i scans on macOS |
ss |
Fast, modern | Linux-only, terse output, no process-friendly naming |
netstat |
Universally present | Deprecated on Linux, inconsistent flags per OS, no kill capability |
fuser |
One-shot kill-by-port | No listing, no context, no safety rails |
kill-port (npm) |
Zero-config | Needs a Node runtime, no real cross-platform binary, no diagnosis |
None of them are cross-platform, none of them explain why something is happening, and none of them were designed as a product — they're syscalls with a CLI face.
portctl treats a port as the durable, addressable thing worth asking
about — not the process that happens to be bound to it right now. That
flip matters: processes are ephemeral (PIDs come and go, servers restart),
but developers think in terms of stable ports — "3000 is my frontend,"
"5432 is postgres." Every existing tool gets this backwards, showing a
process table filtered by port instead of an answer about the port itself.
In practice:
- A verdict, not a table.
portctl why 8080explains why a port is behaving unexpectedly in plain English, instead of raw kernel state you have to interpret yourself. - Answers even when nothing is listening. A
TIME_WAITsocket left behind by a process that already exited is still worth explaining — most tools just show you nothing. - Cross-platform by default, one static binary, no runtime dependency.
- No background daemon. Everything is point-in-time; nothing runs when you're not asking it to.
Homebrew (macOS/Linux):
brew install vikas0686/portctl/portctlcurl (macOS/Linux, no Homebrew required):
curl -fsSL https://raw.githubusercontent.com/vikas0686/portctl/main/install.sh | shFrom source:
git clone https://github.com/vikas0686/portctl.git
cd portctl
go build -o portctl ./cmd/portctlRequires Go 1.26+. No third-party dependencies.
Homebrew and curl installs pull prebuilt binaries from GitHub Releases — these don't exist until the first tagged release ships.
$ portctl ls
PROTO PORT PID PROCESS STATE
tcp 3000 82013 node LISTEN
tcp 5432 1204 postgres LISTENGet everything portctl knows about a specific port.
$ portctl 30003000/tcp LISTEN
Owner: node (pid 82013)
Command: node server.js --port 3000
Cwd: ~/projects/web
Need more detail?
$ portctl 3000 --cpu --memoryCPU: 0.4%
Memory: 86.2 MB
why doesn't just tell you what's happening—it explains it.
$ portctl why 30003000/tcp TIME_WAIT
The process that owned this connection has already exited, but the
kernel is still holding the socket in TIME_WAIT.
This commonly happens immediately after restarting a server and is
usually why you see:
address already in use
The socket will typically be released within ~30 seconds.
Gracefully stop the process using a port.
$ portctl kill 3000Skip confirmation:
$ portctl kill 3000 --yesForce kill if needed:
$ portctl kill 3000 --forceLeave a live view running in a spare pane — refreshes on an interval and flags what showed up or disappeared since the last refresh.
$ portctl watchportctl watch — every 1s — 14:32:07 — ctrl-c to quit
PROTO PORT PID PROCESS STATE
tcp 3000 82013 node LISTEN
tcp 5432 1204 postgres LISTEN
+ tcp/3000 node (pid 82013)
Narrow it to one port, and control the refresh rate:
$ portctl watch 3000 -n 2Every long-running dev session accumulates leftovers: a server you
restarted from a directory you've since deleted, a rebuilt binary whose
old process is still bound to the port. clean finds processes that show
strong evidence of being stale — not just old — and, with confirmation,
kills them the same way kill does.
$ portctl cleanPotentially stale processes:
3000/tcp
node (pid 8123)
~/projects/old-app
reason: working directory no longer exists (~/projects/old-app)
Kill these processes? [y/N]
See what would be cleaned without touching anything:
$ portctl clean --dry-runSkip the confirmation prompt:
$ portctl clean --yesclean only flags a process when its working directory or executable has
actually been deleted out from under it — a merely old or reparented
process is never enough on its own. It also never touches known
system/session daemons or Docker's own manager processes.
Sometimes the process on a port isn't the story — it's a build tool, a
process manager, or a wrapper script three layers deep, and killing it
directly just gets it respawned. tree walks up from the port to the
process that actually owns the session, so you know what you're really
dealing with before you kill anything.
$ portctl tree 30003000/tcp
└── node (pid 8123)
└── npm (pid 8101)
└── zsh (pid 8012)
└── login (pid 501)
Run it with no port to see every listening port's ancestry at once:
$ portctl treels tells you what's bound to a port; it doesn't tell you that pid 8123
is your dev server rather than some random daemon. services groups the
same port table by the developer-facing service it recognizes — inferred
from process name, command line, and port, not a hardcoded app database.
$ portctl servicesSERVICE PORT PROCESS SOURCE
Vite 3000 node ~/projects/shop
PostgreSQL 5432 postgres ~/projects/shop
Redis 6379 redis-server Docker
services names the runtime or framework it has real evidence for (Vite,
Next.js, PostgreSQL, Spring Boot, …), not the project — it doesn't try to
guess that pid 8123 is "your frontend"; the working directory does that
job instead. Something it doesn't recognize shows up honestly as Unknown service rather than a wrong specific guess.
Inspect just one port:
$ portctl services 5432ls, tree, and services answer three different questions:
ls → What ports exist?
tree → Where did this process come from?
services → What services are running?
ls, info, why, clean, tree, and services all take --json for
piping into jq or feeding another tool, instead of scraping the
table/prose output.
$ portctl ls --json | jq '.[] | select(.port == 3000)'{
"proto": "tcp",
"port": 3000,
"pid": 82013,
"process": "node",
"state": "LISTEN"
}clean --json is report-only: it never prompts and never kills, even
with --yes — pipe the candidates to your own tooling and decide from
there.
$ portctl tree 3000 --json[
{
"proto": "tcp",
"port": 3000,
"ancestry": [
{ "pid": 8123, "process": "node" },
{ "pid": 8101, "process": "npm" },
{ "pid": 8012, "process": "zsh" }
]
}
]$ portctl services --json | jq '.[] | select(.source == "DOCKER")'{
"service": "Redis",
"proto": "tcp",
"port": 6379,
"process": "docker-proxy",
"source": "DOCKER",
"confidence": 70
}confidence (0–100) reflects how sure the match is — e.g. a database
recognized directly by its own process name scores higher than a generic
"has python in its command line" match. source is always the plain
LOCAL/DOCKER/SYSTEM/UNKNOWN value here, unlike the text table
where LOCAL is rendered as the working directory instead.
| Command | What it does | Example |
|---|---|---|
portctl ls |
List everything listening locally. Bare portctl is an alias for this. |
portctl ls |
portctl info <port> |
Full detail on one port: owner, command, cwd, optionally CPU/memory. | portctl info 8080 --cpu --memory |
portctl <port> |
Shorthand for portctl info <port> — the port is the thing you're addressing. |
portctl 8080 --cpu |
portctl why <port> |
Plain-English diagnosis of a port's state — why it's stuck, not just what's on it. | portctl why 8080 |
portctl kill <port> |
Kill whatever owns a port. Confirms by default. | portctl kill 8080 -y |
portctl watch [port] |
Live-updating ls, highlighting ports as they appear/disappear. |
portctl watch 3000 -n 2 |
portctl clean |
Find (and, with confirmation, kill) stale/orphaned dev processes occupying ports. | portctl clean --dry-run |
portctl tree [port] |
Show the process ancestry that owns a port — parent, grandparent, and up. | portctl tree 3000 |
portctl services [port] |
Group ports by the developer-facing service recognized behind them. | portctl services |
| Flag | Applies to | Effect |
|---|---|---|
--cpu |
info |
Show CPU utilization (average since process start) |
--memory, --mem |
info |
Show resident memory (RSS) |
--json |
ls, info, why, clean, tree, services |
Machine-readable output instead of table/prose |
-y, --yes |
kill, clean |
Skip the confirmation prompt |
--force |
kill |
Send SIGKILL instead of SIGTERM |
-n, --interval <secs> |
watch |
Refresh interval in seconds (default 1) |
--dry-run |
clean |
Report what would be cleaned; never kills anything |
portctl uses the most direct source of truth available on each operating system while exposing the same CLI everywhere.
| Platform | Implementation |
|---|---|
| Linux | Reads kernel networking information directly from /proc, correlating sockets with processes without invoking external commands. |
| macOS | Uses native system tools (lsof, ps, and netstat) behind a common abstraction layer. This avoids cgo today while keeping the backend replaceable with a native implementation in the future. |
| Windows | Planned. |
Early days — issues and PRs welcome, but expect the internals to move
around a lot until the core (ls/info/kill/watch) settles.
