Your app logs say ECONNRESET. Someone closed the socket — but who? Your app, the peer, or a middlebox nobody told you about? Most engineers jump straight to tcpdump, capture 40,000 packets, and drown. The faster path is to read the socket states you already have.
Here's the 5-minute drill I run, in order.
Minute 1: Get the state distribution
ss -tn | awk '{print $1}' | sort | uniq -c | sort -rn
Four states actually tell you something:
| State | What it means | Threshold that matters |
|---|---|---|
TIME-WAIT |
You closed first (normal churn) | Low thousands on a proxy is fine; hundreds of thousands is not |
CLOSE-WAIT |
Peer closed, your app never did | > 100 is a bug in your code, not the network |
SYN-SENT |
Your outbound connect got no answer | > 100 means a path/firewall problem |
SYN-RECV |
Half-open inbound connections | Growing = accept queue full or a scan |
The one line I want burned into memory: CLOSE-WAIT piling up is always a local application bug. The peer sent FIN, your process never called close(), and that socket will sit there forever. Find the owner:
ss -tnp state close-wait
Minute 2: Check the kernel counters
netstat -s | grep -iE 'retrans|reset|listen|overflow'
Three counters matter:
-
TCPRetransFailclimbing → packets are being dropped somewhere on the path -
ListenOverflowsgrowing → your accept queue is full; real SYNs are being refused - resets growing steadily → someone is killing connections mid-flight
If all of them are flat, TCP is not your problem — stop reading socket tables and go look at the application.
Minute 3: Attribute the RST
This is the step most people skip, and it's the one that ends the argument about "whose fault" it is:
tcpdump -nn -i any 'tcp[tcpflags] & tcp-rst != 0'
Read the source IP of each RST:
- RST from your own IP → your application closed the socket while the peer was still sending. Look for a crash or an over-eager timeout in your own logs.
- Source is the peer → they closed first; you sent into a dead socket.
- RST from a third IP (or a load balancer) → a middlebox with a short idle timeout is pruning your session. The classic case: your app sends on an idle connection every 90 seconds, but the load balancer's idle timeout is 60 seconds. The LB closed silently; your next packet gets an RST.
That third case is the one I see most, and it's a config mismatch, not a bug: align the LB idle timeout with your app's send interval, or send socket-level keepalives (TCP_KEEPIDLE=60, TCP_KEEPINTVL=10, TCP_KEEPCNT=3 — probes start at 60s and give up after ~90s total).
One real example
A reverse proxy showed ~4,000 CLOSE-WAIT sockets at 2 AM. ss -tnp state close-wait showed every one owned by the same worker process. The app had an error path that returned without closing upstream connections — the peer (the upstream) closed, and the leaked sockets sat there forever. The fix was three lines of try/finally, not a single sysctl. Kernel tuning would have just hidden the leak.
The order that works
-
ss -s— size the problem. - State distribution (
ss -tn | awk ... | uniq -c) → tells you which side is misbehaving. -
netstat -scounters → tells you whether the kernel even agrees there's a problem. - Only then
tcpdump, and only on the specific host/port pair you care about.
Nginx logs tell you the symptom, ss tells you the state, tcpdump tells you the packet-level truth — in that order. Tuning sysctl before you know which side is doing the unusual thing just moves the symptom.
Tags: tcp, networking, linux, debugging, devops
More field-tested network and server notes: jiunotes · ops notes
Top comments (0)