When a Java or Python service starts refusing work and the box shows thousands of sockets in CLOSE_WAIT, the network is almost never the cause. CLOSE_WAIT means the other side already hung up and your own process never called close(), so the kernel keeps the socket open for it, forever. Counting TCP connection states with netstat or ss shows you that in under a minute, and the count tells you which process to look at before you touch any sysctl.
Below you’ll check TCP connection states in Linux with both tools, map CLOSE_WAIT and TIME_WAIT back to a PID, read the kernel timers and clear a leak. We caused each state on purpose with a Python listener that never closes its sockets and a burst of requests to Nginx, and every command ran on Debian 13 with net-tools 2.10 and iproute2 6.15 in October 2026.
Install netstat on a current distro
A default Debian 13 install has no netstat at all, because it ships in the legacy net-tools package, which isn’t part of the base system. What you do get is ss, part of iproute2, which reads the same kernel socket tables faster and accepts state filters. On a fresh Debian 13 box, check all three before you install anything.
netstat -nat; dpkg -l net-tools; ss -V
On the fresh lab box, all three answered like this:
bash: netstat: command not found
dpkg-query: no packages found matching net-tools
ss utility, iproute2-6.15.0
netstat isn’t found and dpkg has never heard of the package, but ss is already there. That last line matters, because every netstat command in this guide has an ss twin you can run with nothing installed.
Error: “netstat: command not found”
That message means net-tools is missing, nothing more. If you only need state counts, you can skip the install and use the ss commands shown next to each netstat command below. Install it when your old scripts or runbooks call netstat by name. We added Nginx in the same step, because the TIME_WAIT examples later need a local web server listening on port 80 to connect to; leave it out if you only want netstat.
sudo apt-get install -y net-tools nginx
The install takes a few seconds and pulls nothing else in. Confirm the version afterwards, so you know which netstat your output came from:
netstat --version | head -2
Debian 13 installs net-tools 2.10.
net-tools 2.10
Fred Baumgarten, Alan Cox, Bernd Eckenfels, Phil Blundell, Tuan Hoang, Brian Micek and others
The package has the same name on Ubuntu and on the RHEL family, where you install it with sudo dnf install net-tools, though we only ran it on Debian for this refresh. The same package also brings back ifconfig and route, which our ifconfig install guide for Ubuntu and Debian covers on its own.
List the TCP connection states
Every TCP socket the kernel tracks sits in exactly one state, and the state tells you which side of the conversation is waiting on whom. netstat prints the names with underscores and ss uses hyphens. That turns TIME_WAIT into TIME-WAIT, and ss also shrinks ESTABLISHED to ESTAB. A grep you wrote for one tool silently matches nothing in the other. The last column shows which states we produced on the lab box.
| netstat name | ss name | What it means | Seen in lab |
|---|---|---|---|
| LISTEN | LISTEN | Waiting for incoming connections | Yes |
| SYN_SENT | SYN-SENT | Sent a SYN, no reply yet | Yes |
| SYN_RECV | SYN-RECV | Received a SYN, handshake not finished | No |
| ESTABLISHED | ESTAB | Open connection passing data | Yes |
| FIN_WAIT1 | FIN-WAIT-1 | We closed, waiting for the peer to acknowledge | No |
| FIN_WAIT2 | FIN-WAIT-2 | We closed, peer acknowledged, waiting for its FIN | Yes |
| CLOSE_WAIT | CLOSE-WAIT | Peer closed, our application has not | Yes |
| LAST_ACK | LAST-ACK | We closed after the peer, waiting for the final ACK | No |
| CLOSING | CLOSING | Both sides closed at once | No |
| TIME_WAIT | TIME-WAIT | We closed first, holding the port for late packets | Yes |
Two rows matter more than the rest. TIME_WAIT always lands on the side that closed first, and the kernel clears it on a timer, so a large number is normal for a busy client or proxy. CLOSE_WAIT has no timeout of its own. The owning process ends it by closing the socket, which is why a growing CLOSE_WAIT count points at application code every time. If you still need the difference between the two tools’ flags, the netstat vs ss usage guide lines them up side by side.
Check TCP connections by state
The classic one-liner prints the sixth column of netstat -nat, which is the state, and counts each value. Run it first on any box that misbehaves, because it turns a few thousand lines into a short tally. The flags are the same ones you will reuse through the rest of this guide:
-nnumeric addresses and ports, no DNS lookups-aall sockets, listening ones included-tTCP only-powning PID and program, needs root for other users-okernel timers such as the TIME_WAIT countdown
ss accepts the same letters, so ss -tan and netstat -nat list the same sockets. Count them by state:
netstat -nat | awk '{print $6}' | sort | uniq -c | sort -rn
On our test box with the leak running, the tally came back like this. Your numbers will differ, but the shape of the list is what you read.
150 TIME_WAIT
40 FIN_WAIT2
40 CLOSE_WAIT
9 LISTEN
1 established)
1 Foreign
1 ESTABLISHED
The established) and Foreign lines are not sockets. They are words from netstat’s two header lines that happen to sit in column six, and most copies of this one-liner on the web leave them in. Skip the first two lines with NR>2 and your tally only contains real states.
netstat -nat | awk 'NR>2 {print $6}' | sort | uniq -c | sort -rn
The ss version needs no awk trick, because -H drops the header and the state is already the first column:
ss -Htan | awk '{print $1}' | sort | uniq -c | sort -rn
The counts match netstat exactly. Only the spelling of the states differs, which is the grep trap from the table above.
150 TIME-WAIT
40 FIN-WAIT-2
40 CLOSE-WAIT
9 LISTEN
1 ESTAB
Read the tally from the top. 150 TIME_WAIT sockets after a burst of short HTTP requests is the kernel doing its job, and they will be gone in about a minute. The 40 CLOSE_WAIT and 40 FIN_WAIT2 are the two halves of the same 40 connections. Our client closed, which left FIN_WAIT2 on its side, and the listener never closed back, which left CLOSE_WAIT on its own. The peer address in those sockets is 127.0.0.1, which is the evidence that both ends live on this host, so the hunt stays on one machine.
Find the process holding CLOSE_WAIT sockets
Counting tells you there is a leak, and the process column tells you who owns it. Add -p and run it with sudo, because netstat can only see the PID of sockets owned by other users when it runs as root. Without sudo you get a dash for those sockets, and the count collapses into a single useless line.
sudo netstat -tanp | grep CLOSE_WAIT | awk '{print $7}' | sort | uniq -c | sort -rn | head
All 40 belong to one PID. On a real server you’d expect a handful of PIDs here, and the one at the top is your suspect.
40 9792/python3
ss can filter by state on its own, so you don’t need grep. Its output also shows the local port the leaking listener sits on and the file descriptor of each socket.
sudo ss -tanp state close-wait | head -3
Each line is one connection that the peer finished with and our process kept.
Recv-Q Send-Q Local Address:Port Peer Address:Port Process
1 0 127.0.0.1:9000 127.0.0.1:34930 users:(("python3",pid=9792,fd=11))
1 0 127.0.0.1:9000 127.0.0.1:34892 users:(("python3",pid=9792,fd=6))
The Recv-Q of 1 on every line is the giveaway. That byte is the peer’s FIN, which the kernel counts as one unread unit in the receive queue, so the application never even read to end-of-file, let alone closed the socket. Take the PID to ps to see what it is and how long it has been running:
ps -o pid,etime,cmd -p 9792
It is our deliberately leaky listener, 74 seconds old. A long ELAPSED next to a slowly rising count is the usual pattern on a real box.
PID ELAPSED CMD
9792 01:14 python3 leaky.py
The full check, as it ran on the lab box, goes from the state tally through to the owning PID.

On a real server the command line usually names the culprit, such as a JVM, a worker pool or a client library that opens a connection per request and never returns it. That is where you will fix it, so hand the PID and the port to whoever owns that code.
Wait for TIME_WAIT to expire
TIME_WAIT sockets carry a countdown, and both tools will show it if you ask with -o. Seeing the timer is the fastest way to prove that a pile of TIME_WAIT is draining rather than stuck. List a few of them first.
ss -tan state time-wait | head -3
They all point at port 80, which is Nginx, because our client closed those connections first and so it holds the TIME_WAIT.
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 0 127.0.0.1:49808 127.0.0.1:80
0 0 127.0.0.1:49886 127.0.0.1:80
Now add -o to read the timer. The request goes to the Nginx we installed earlier, so you need a web server listening on port 80 locally for this step. Make one fresh request so the countdown starts near its maximum, then print the same socket with both tools.
curl -s -o /dev/null http://127.0.0.1/
ss -tano state time-wait | sed -n 1,2p
netstat -tano | grep TIME_WAIT | head -1
Both report the same socket with just under 60 seconds left.
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 0 127.0.0.1:46632 127.0.0.1:80 timer:(timewait,59sec,0)
tcp 0 0 127.0.0.1:46632 127.0.0.1:80 TIME_WAIT timewait (59.99/0/0)
Sixty seconds is the kernel’s built-in TIME_WAIT length. The setting people usually reach for, net.ipv4.tcp_fin_timeout, does not change it. The kernel’s ip-sysctl documentation defines it as how long an orphaned connection stays in FIN_WAIT2, which is a different state. You can read its current value straight from /proc.
cat /proc/sys/net/ipv4/tcp_fin_timeout
One number comes back:
60
It is at the default of 60 seconds, which is why the FIN_WAIT2 sockets in our tally were gone at the next count.
Error: “command not found: sysctl”
We hit this on the same box when we first tried sysctl net.ipv4.tcp_fin_timeout as a normal user. Debian installs sysctl in /usr/sbin, which isn’t in a regular user’s PATH, so our zsh session reported zsh:1: command not found: sysctl. Either run sudo sysctl net.ipv4.tcp_fin_timeout, or read the file under /proc/sys as shown above, which needs no privileges at all.
About 70 seconds after the burst we counted again with the NR>2 version of the tally. Every TIME_WAIT and every FIN_WAIT2 socket had gone without anyone touching them, while all 40 CLOSE_WAIT sockets were still there.
40 CLOSE_WAIT
9 LISTEN
1 ESTABLISHED
That single comparison is the most useful thing in this guide. States the kernel owns drain on their own within a minute, but CLOSE_WAIT has no timeout of its own and does not move until the application acts. The one exception is a socket with SO_KEEPALIVE set, where a dead peer can eventually get the connection reset by the kernel.
Find connections stuck in SYN_SENT
SYN_SENT is the outbound mirror of a leak. Your host sent the first packet of a handshake and nothing came back. A firewall that drops instead of rejecting produces exactly this, and so does a dead upstream. We reproduced it by connecting to 192.0.2.10, a documentation address that nothing answers on.
(timeout 20 nc -w 15 192.0.2.10 443 &) ; sleep 2; ss -tan state syn-sent; netstat -nat | grep SYN_SENT
Both tools catch the pending handshake from the server’s own address.
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 1 10.0.1.118:54642 192.0.2.10:443
tcp 0 1 10.0.1.118:54642 192.0.2.10:443 SYN_SENT
A Send-Q of 1 is the unacknowledged SYN. One or two of these that come and go is normal. A steady crowd of them to the same peer and port means that destination is filtered or down, so check the path with a port scan before you blame the application. Our nmap scanning guide covers the flags you need.
Restart the owner to clear CLOSE_WAIT
The only way to clear CLOSE_WAIT is to make the owning process close its sockets, and the blunt version of that is restarting or stopping the process. We stopped the leaky listener and counted again.
pkill -f leaky.py
sleep 1
netstat -nat | grep -c CLOSE_WAIT
A second after the process exited, the kernel had closed all 40 sockets.
0
On a production service, restart the unit with sudo systemctl restart to buy time, then fix the code path that forgets to close, because the count will climb back at the same rate. Raising file descriptor limits only moves the wall further out. If the leak sits behind a reverse proxy, our Nginx setup on Debian is a good place to check upstream keepalive settings, since a proxy that pools connections hides how many the backend really opens.
Read the counts: what is normal
No count is bad in isolation. What matters is which state grows and whether it drains. For a quick trend line, ss -s prints a summary without listing sockets, and during the leak it showed this.
Total: 177
TCP: 240 (estab 41, closed 190, orphaned 0, timewait 190)
Those numbers do not match the per-state tally, and that trips people up. The estab figure of 41 is the 1 ESTABLISHED socket plus the 40 CLOSE_WAIT ones, because the kernel’s established counter includes CLOSE_WAIT. The timewait figure of 190 is 150 TIME_WAIT plus the 40 orphaned FIN_WAIT2 sockets (already closed by the app), which the kernel turns into timewait mini-sockets at the default tcp_fin_timeout. After we killed the leak, the same line dropped to estab 1 and timewait 1. Use ss -s for graphs and the per-state count for diagnosis.
When you read your own tally, judge each state by whether it should drain:
- LISTEN: fixed, one per listening port and address
- ESTABLISHED: tracks real traffic and pool sizes
- TIME_WAIT: high on clients and proxies, clears in 60 seconds
- FIN_WAIT2: short-lived, bounded by
tcp_fin_timeout - SYN_SENT: a few at most, a crowd means a filtered peer
- CLOSE_WAIT: should be near zero, any steady growth is a leak
Thousands of TIME_WAIT on a proxy that opens a fresh upstream connection per request is normal, and the cheaper fix is keepalive, not kernel tuning. The server performance tuning guide covers the network sysctls worth changing. A CLOSE_WAIT count that grows between two runs a minute apart is the one number that always deserves a ticket against the application, because CLOSE_WAIT has no timeout of its own and keepalive only rescues sockets that enabled it.