How to Kill a Process on a Port on Mac (Safely, and for Good)
To kill the process on a port on a Mac, look up the listener's PID with lsof and send it SIGTERM with kill:
lsof -nP -iTCP:8080 -sTCP:LISTEN
kill 4821
Or in one line, which also takes a list such as tcp:3000,5173,8080:
kill $(lsof -ti tcp:8080 -sTCP:LISTEN)
Run the lsof line again afterwards. No output means the port is free. Keep -sTCP:LISTEN, or lsof also returns the clients connected to the port, and you end up killing your browser's network process.
Use kill first, and kill -9 only if that is ignored
Plain kill sends SIGTERM, a request the program can act on: servers close their connections, databases finish their writes, and npm and nodemon stop the server they started. kill -9 sends SIGKILL, which can't be caught, so none of that happens.
npm run dev shows the difference. kill on the npm process stops npm and the Node server under it. kill -9 on the same process leaves the server running with parent PID 1, still holding the port. Databases take it worse: the PostgreSQL docs warn that SIGKILL keeps the server from releasing shared memory and semaphores.
That leaves -9 for one case: the process is yours, you sent SIGTERM, a few seconds have passed and it is still listening. Send it to the PID that lsof shows rather than to its parent.
killall node and pkill -f vite pick by name, so they also hit your other projects and your editor's TypeScript server. killall -s node is a dry run that only lists what it would signal. Count the lines before you run the real one.
Why is the port still in use after kill?
Look at the process before you reach for -9:
ps -o pid,ppid,stat,command -p 4821
A T in the STAT column means it is suspended, usually because someone pressed Control-Z instead of Control-C. A suspended Node server keeps its port and doesn't act on SIGTERM until it is resumed. Run fg in its terminal, or wake it yourself:
kill -CONT 4821
A PPID of 1 on a dev server is the orphan from the section above. Its wrapper is gone, so nothing brings it back. Kill its own PID.
If lsof shows a different PID on the same port, something restarted the process.
When the process comes back
Something supervises the process and started a new copy, so it has to be stopped through that supervisor.
launchd restarts every job whose plist sets KeepAlive, and that includes Homebrew services. This finds the job that owns a PID:
launchctl list | awk -v pid=1187 '$1 == pid'
1187 0 homebrew.mxcl.postgresql@17
The last column is the label. For Homebrew, brew services stop postgresql@17 stops the service and keeps it from starting at login. For a launch agent you installed yourself:
launchctl bootout gui/$(id -u)/com.example.devserver
Always include the label. Without it, bootout targets your whole login session. Leave com.apple.* labels alone.
pm2 brings a killed app back within a second. Use pm2 stop api, or pm2 delete api to remove it from pm2's list.
nodemon and other file watchers start their child again on the next save, so stop the watcher itself.
For a published container port, the listener on the Mac is com.docker.backend or OrbStack Helper. Those forward the ports of every container, so don't kill them. Stop the container:
docker ps --filter publish=8080
docker stop web
When you need sudo
You need it only when root or another user owns the process. lsof without sudo lists just your own processes, so an empty result doesn't prove the port is free:
sudo lsof -nP -iTCP:8080 -sTCP:LISTEN
sudo kill 312
Without sudo, the kill fails with "operation not permitted". Read the USER column first. If it says root or starts with an underscore, the process usually belongs to macOS or a system-wide service, and launchd will restart it. Turn off the feature that opened the port instead. For port 22 that is Remote Login in System Settings › General › Sharing. For ports 5000 and 7000 it is AirPlay Receiver, and the other Apple listeners mostly have a switch of their own.
Portis puts the lookup and the kill in one window. Search for the port, read the User, PWD and Args columns to be sure it is the right process, then select the row and choose Kill Process. That sends SIGTERM, never SIGKILL, and only to your own processes. Root's ports are in the list, but ending them still takes sudo kill in Terminal.