Finding What's on a Port (and Killing It Safely)
- cli
- bun
- dev-tools
- macos
You know the ritual.
Error: listen EADDRINUSE: address already in use :::3000
Copy 3000. Run lsof -i :3000. Squint at the PID. kill -9. Try again. I built this into my personal CLI so I'd stop doing it by hand — but you don't need a whole CLI to get the same value. A few shell functions will do.
The one-liner everyone writes first
alias killport='lsof -ti :$1 | xargs kill -9'
This works, but it's a foot-gun. Plenty of well-behaved apps listen on unexpected ports — VS Code, Slack, Docker, your local Postgres — and a reflex kill -9 will happily take any of them down. You want something that looks first and refuses to do the dumb thing by default.
A safer version you can paste into your shell
Drop this in your .zshrc or .bashrc. It looks up what's on a port, refuses to touch a short list of processes you almost certainly didn't mean to kill, and confirms before anything dies.
# What's on this port?
port() {
local p="${1:?usage: port <number>}"
lsof -nP -iTCP:"$p" -sTCP:LISTEN
}
# Kill what's on this port, carefully.
killport() {
local p="${1:?usage: killport <number>}"
local pid name
pid="$(lsof -nP -tiTCP:"$p" -sTCP:LISTEN | head -n1)"
if [[ -z "$pid" ]]; then
echo "nothing listening on $p"
return 0
fi
name="$(ps -p "$pid" -o comm= | xargs basename)"
# Protected processes: refuse unless --force.
local protected=(Code Electron Slack Discord Figma Notion \
Chrome firefox Safari Arc Brave \
postgres mysqld mongod redis-server \
com.docker.backend Docker \
launchd rapportd ControlCe SystemUIServer)
for proc in "${protected[@]}"; do
if [[ "$name" == "$proc" && "$2" != "--force" ]]; then
echo "refusing to kill $name (pid $pid) on port $p"
echo "pass --force if you really mean it"
return 1
fi
done
printf "kill %s (pid %s) on port %s? [y/N] " "$name" "$pid" "$p"
read -r reply
[[ "$reply" == "y" || "$reply" == "Y" ]] || { echo "aborted"; return 1; }
kill -15 "$pid" 2>/dev/null || kill -9 "$pid"
}
# Check the usual dev-server suspects.
ports() {
for p in 3000 3333 4321 5173 8080 8000 8888 4200; do
local hit
hit="$(lsof -nP -tiTCP:"$p" -sTCP:LISTEN 2>/dev/null)"
[[ -n "$hit" ]] && echo "$p: $(ps -p "$hit" -o comm= | xargs basename)"
done
}
Now port 3000 shows you what's there, killport 3000 asks before killing, and ports sweeps the common Vite/Next/Astro/Express/Rails defaults so you can find the stuck one without guessing.
The small idea behind it
The broader lesson for me wasn't about ports. Small frictions are worth automating, but only if the automation is safer than what it replaces. A blind killport alias is a foot-gun waiting to go off mid-demo. A version that knows what it's looking at, shows you, and refuses to touch the things you'd regret killing is actually better than the manual ritual — not just faster.
Every time the protected list catches me about to do something stupid, it justifies the whole thing.