You start your dev server and get this:

Error: listen EADDRINUSE: address already in use :::3000

Something is already bound to port 3000. Maybe a crashed process, a forgotten background job, or a previous run of the same server that didn’t shut down cleanly. You need to find what’s on the port, then kill it.

This is one of the most common tasks in day-to-day development, and the commands are different depending on your OS, which tool is available, and what output format you need. This post covers every reliable method.

lsof -i :PORT

lsof stands for “list open files.” On Unix systems, network sockets are files, so lsof can find processes bound to network ports. It works on both macOS and Linux, though some flags behave differently.

Basic usage

lsof -i :3000

This lists all processes with any connection involving port 3000 — listening, established, TIME_WAIT, everything. On a busy server, this can return a lot of rows.

The output looks like this:

COMMAND   PID   USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
node    12345   user   23u  IPv6 0x1234      0t0  TCP *:3000 (LISTEN)
node    12345   user   24u  IPv6 0x5678      0t0  TCP localhost:3000->localhost:52431 (ESTABLISHED)

Useful flags

-P (no port name resolution): Without this, lsof may show hbci instead of 3000 because it looks up port names from /etc/services. Always use -P if you want to see actual port numbers.

-n (no hostname resolution): Prevents DNS lookups on IP addresses. Faster output and avoids hangs when DNS is misconfigured.

-sTCP:LISTEN (listening sockets only): Filters to only processes that are listening on the port — the ones actually blocking it. This is usually what you want.

-t (terse/PID only): Outputs just the PID, one per line. Ideal for piping into kill.

# See what's listening on port 3000 (fast, readable)
lsof -i :3000 -P -n -sTCP:LISTEN

# Get just the PID (for scripting)
lsof -i :3000 -P -n -sTCP:LISTEN -t

# Find and kill in one line
lsof -i :3000 -t | xargs kill

# Check a range of ports
lsof -i :3000-3010 -P -n -sTCP:LISTEN

macOS vs Linux differences

On macOS, lsof is always available — it ships with the OS.

On Linux, lsof may or may not be installed. Most distributions include it, but minimal Docker images and Alpine-based containers often don’t. You can install it with apt install lsof or yum install lsof.

One subtle difference: on macOS, lsof -i :3000 may show both IPv4 and IPv6 entries separately. On Linux, a single socket can listen on both protocols (dual-stack), so you might see only one entry.

ss -tlnp (Linux only)

ss is the modern replacement for netstat on Linux. It’s faster and gives more detailed socket information. It reads directly from kernel data structures via netlink, while netstat parses /proc/net/tcp.

ss is Linux-only. It does not exist on macOS.

Flag breakdown

ss -tlnp
  • -t — TCP sockets only (use -u for UDP, or both for -tu)
  • -l — Listening sockets only (not established connections)
  • -n — Numeric output (don’t resolve service names)
  • -p — Show process information (which process owns the socket)

Filtering by port

You can grep the output, but ss also has built-in filter syntax:

# Grep approach
ss -tlnp | grep :3000

# Native filter syntax
ss -tlnp 'sport = :3000'

# Port range (native syntax)
ss -tlnp 'sport >= :3000 and sport <= :3010'

The native filter syntax is more reliable than grep because it won’t accidentally match a PID or other field that happens to contain “3000.”

Output format

State    Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN   0       128      0.0.0.0:3000         0.0.0.0:*          users:(("node",pid=12345,fd=23))

The process information is in a structured but unusual format: users:(("name",pid=N,fd=N)). Extracting the PID requires some parsing (covered in the awk section below).

Note on permissions

ss -p needs root privileges to show process information for sockets owned by other users. If you run it without sudo and the PID column is empty, that’s why.

# If process column is blank, try:
sudo ss -tlnp 'sport = :3000'

netstat

netstat is the classic tool for network statistics. It exists on both macOS and Linux, but the flags and output differ significantly between platforms.

Linux

netstat -tlnp
  • -t — TCP
  • -l — Listening only
  • -n — Numeric (no name resolution)
  • -p — Show PID and program name

Output:

Proto Recv-Q Send-Q Local Address     Foreign Address   State    PID/Program name
tcp   0      0      0.0.0.0:3000      0.0.0.0:*         LISTEN   12345/node

Note: netstat is deprecated on Linux. It’s part of the net-tools package, which is no longer installed by default on many distributions (Ubuntu 18.04+, Fedora, Arch). Use ss instead. If you’re writing scripts that need to be portable across old and new Linux systems, check for ss first, then fall back to netstat.

macOS

macOS has netstat but it does not support the -p flag. It cannot show which process owns a socket.

# macOS: shows listening TCP sockets, but no PID
netstat -an | grep LISTEN | grep '\.3000 '

# macOS: more verbose, includes some process info
netstat -anv | grep LISTEN | grep '\.3000 '

On macOS, netstat -anv adds a few more columns but still doesn’t reliably show the PID for all sockets. Use lsof on macOS instead — it’s the right tool there.

Key difference summary

Feature Linux netstat -tlnp macOS netstat -an
Shows PID Yes No
-p flag Shows PID/name Protocol filter (different meaning)
Port format 0.0.0.0:3000 *.3000 or 127.0.0.1.3000
Status Deprecated (use ss) Still the system netstat

fuser PORT/tcp

fuser identifies processes using files or sockets. It’s available on most Linux systems and some macOS setups (via brew install psmisc), though it’s primarily a Linux tool.

Basic usage

fuser 3000/tcp

Output:

3000/tcp:            12345

That’s it — just the port and PID. Quick, but minimal.

Important quirk: stderr

fuser prints the port label (3000/tcp:) to stderr and the PID to stdout. This matters for scripting:

# This captures the PID only
pid=$(fuser 3000/tcp 2>/dev/null)

# This shows nothing (stderr was swallowed, stdout has just the PID)
fuser 3000/tcp 2>/dev/null
# Output: 12345

# Common mistake — this captures the label too
result=$(fuser 3000/tcp 2>&1)
# result = "3000/tcp:            12345"

The -k flag

fuser -k 3000/tcp

This sends SIGKILL to every process on the port. No confirmation, no preview. It kills immediately. Be cautious with this on production systems where multiple processes might share a port (e.g., behind a load balancer or with SO_REUSEPORT).

You can specify a signal:

fuser -k -TERM 3000/tcp   # Send SIGTERM instead of SIGKILL
fuser -k -INT 3000/tcp    # Send SIGINT

Extracting PIDs with awk

Each tool has a different output format. Here’s how to parse each one to get a clean PID.

From lsof

# Skip the header line (NR>1), print the PID column ($2)
lsof -i :3000 -P -n | awk 'NR>1 {print $2}'

Or just use lsof -t which does this for you. But the awk version is useful when you want to extract other columns too:

# Get PID and command name
lsof -i :3000 -P -n | awk 'NR>1 {print $2, $1}'

From ss

ss buries the PID inside a structured string like users:(("node",pid=12345,fd=23)). Extracting it:

# Using grep -oP (Perl regex, Linux only)
ss -tlnp | grep :3000 | grep -oP 'pid=\K[0-9]+'

# Using awk and match (gawk only — capture groups are a gawk extension)
ss -tlnp | grep :3000 | awk '{match($0, /pid=([0-9]+)/, a); print a[1]}'

# Using sed
ss -tlnp | grep :3000 | sed -n 's/.*pid=\([0-9]*\).*/\1/p'

Note: grep -P (Perl-compatible regex) is available on Linux via GNU grep but not on macOS by default. On macOS you’d use grep -oE with a slightly different pattern, but since ss itself is Linux-only, this is fine.

From netstat (Linux)

# The PID/name is in column 7, formatted as "12345/node"
netstat -tlnp 2>/dev/null | awk '/:3000 / {split($7, a, "/"); print a[1]}'

The split($7, a, "/") breaks 12345/node on the / delimiter and takes the first part (the PID).

From fuser

# fuser already outputs just the PID to stdout
fuser 3000/tcp 2>/dev/null

If multiple PIDs are returned (space-separated), you can iterate:

fuser 3000/tcp 2>/dev/null | xargs -n1 echo

Quick reference table

Tool OS Command Output
lsof macOS, Linux lsof -i :3000 -P -n Full table (PID, user, command, etc.)
lsof macOS, Linux lsof -i :3000 -t PID only, one per line
ss Linux ss -tlnp 'sport = :3000' Table with process info in parens
netstat Linux netstat -tlnp \| grep :3000 Table with PID/name column
netstat macOS netstat -anv \| grep '\.3000 ' Table, no PID
fuser Linux fuser 3000/tcp PID to stdout, label to stderr

Killing the process

Once you have the PID, you need to send it a signal.

kill PID (SIGTERM, signal 15)

kill 12345
# Equivalent to:
kill -15 12345
kill -TERM 12345

SIGTERM asks the process to shut down gracefully. The process can catch this signal, finish writing files, close database connections, and exit cleanly. This is what you should try first.

kill -9 PID (SIGKILL)

kill -9 12345
kill -KILL 12345

SIGKILL immediately terminates the process. It cannot be caught, blocked, or ignored. The kernel removes the process without giving it a chance to clean up. Use this when:

  • kill (SIGTERM) didn’t work after a few seconds
  • The process is completely unresponsive
  • You don’t care about graceful shutdown (e.g., a dev server)

Don’t default to kill -9. It can leave temporary files, lock files, corrupted databases, and orphaned child processes. It’s the “pull the power cord” option.

kill -2 PID (SIGINT)

kill -2 12345
kill -INT 12345

SIGINT is the same signal sent by Ctrl+C. Some applications handle SIGINT differently from SIGTERM — for example, a Node.js server might print a shutdown message and clean up connections on SIGINT because that’s what the developer tested.

The full sequence for a stubborn process

# 1. Ask nicely
kill 12345

# 2. Wait a moment
sleep 2

# 3. Check if it's gone
kill -0 12345 2>/dev/null && echo "still running" || echo "stopped"

# 4. If still running, force it
kill -9 12345

kill -0 doesn’t send a signal — it just checks whether the process exists. The exit code tells you: 0 means it’s still alive, non-zero means it’s gone.

One-liner patterns

Putting it all together — find the PID and kill it in one command:

# lsof (macOS and Linux)
lsof -i :3000 -t | xargs kill

# lsof with force kill
lsof -i :3000 -t | xargs kill -9

# fuser (Linux)
fuser -k -TERM 3000/tcp

# ss + awk + kill (Linux)
kill $(ss -tlnp 'sport = :3000' | grep -oP 'pid=\K[0-9]+')

Gotchas

Root-owned processes require sudo

If a process was started by root (or another user), you can’t kill it as your regular user. You also might not see it in ss -p or fuser output without elevated privileges.

# If "Operation not permitted":
sudo lsof -i :80 -t | sudo xargs kill
sudo fuser -k 80/tcp

Ports below 1024 typically require root to bind, so processes on ports like 80 or 443 are often root-owned.

IPv4 vs IPv6

A process may listen on IPv4 (0.0.0.0:3000), IPv6 ([::]:3000), or both. Most modern applications use dual-stack sockets, listening on IPv6 which also accepts IPv4 connections.

lsof -i :3000 shows both by default. If you want to filter:

lsof -i 4TCP:3000   # IPv4 only
lsof -i 6TCP:3000   # IPv6 only

With ss:

ss -tlnp4 'sport = :3000'   # IPv4
ss -tlnp6 'sport = :3000'   # IPv6

Multiple processes on the same port

With SO_REUSEPORT (common in Go, nginx, and some Node.js cluster setups), multiple processes can listen on the same port. Your lsof or ss query may return several PIDs. Make sure you’re killing the right ones — or all of them, if that’s what you intend.

# See all processes on port 3000
lsof -i :3000 -P -n -sTCP:LISTEN

# Count them
lsof -i :3000 -P -n -sTCP:LISTEN -t | wc -l

TIME_WAIT sockets

After killing a process, the port might still appear “in use” for up to 60 seconds due to TCP TIME_WAIT. This is the kernel keeping the socket open to handle any remaining packets from the closed connection.

You can’t kill a TIME_WAIT socket — there’s no process to kill. You have two options:

  1. Wait. It will clear on its own (usually 30-60 seconds).
  2. Set SO_REUSEADDR in your application, which most frameworks do by default. This lets a new process bind to the port even if TIME_WAIT sockets exist.

If you see an entry in lsof or netstat with no PID and a state of TIME_WAIT, that’s what’s happening.

The one-liner way

All of the above works. But if you’re doing this regularly — killing dev servers, checking what’s on a port, freeing up ports — it’s a lot of flags to remember, and the commands are different on macOS vs Linux.

proc is a cross-platform CLI that handles this with a consistent syntax:

# What's on port 3000?
proc on :3000

# Kill it (asks for confirmation)
proc kill :3000

# Kill without confirmation
proc kill :3000 --yes

# Kill multiple ports at once
proc kill :3000,:8080,:4200

Same commands on macOS and Linux. No flags to remember, no awk pipelines to construct.

Install

brew install yazeed/proc/proc     # macOS
cargo install proc-cli            # Rust
npm install -g proc-cli           # npm/bun

See the GitHub repo for all installation options.