Finding What's on a Port (and Killing It Safely)

  • cli
  • bun
  • dev-tools
  • macos

You know the ritual.

typescript
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

bash
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.

bash
# 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.