TCP Troubleshooting: TIME_WAIT / RST / SYN Half-Open / Retransmission
TCP failures are almost never about TCP itself — they're about something on one side of the socket doing something unusual, and TCP faithfully reflecting it. The failure you observe depends entirely on which side is doing the unusual thing. That's why the first step is always to figure out which side is doing what, not to tune anything.
Before You Touch Anything: 3 Commands That Cut 80% of Cases
Every TCP investigation starts the same way. These three commands take under a minute and eliminate most of the noise.
Command 1: Overall TCP Summary
What you're looking for:
- TCP: 500 (established 400, closing 5, timewait 80, orphaned 0) — healthy baseline for a small server.
- timewait in the low thousands on a reverse proxy is normal. In the hundreds of thousands is not.
- closing > 50 suggests something is closing sockets improperly.
- timewait_orphaned > 0 means sockets were closed without a peer.
Command 2: State Distribution
What each state means when abnormal:
| State | Normal on a small server | What abnormal means |
|---|---|---|
ESTAB |
Proportional to active users | Leak, or a real load spike |
TIME-WAIT |
< 500 unless it's a proxy | Churn from a reverse proxy or client |
CLOSE-WAIT |
< 50 | Local app not closing sockets — always a bug |
SYN-SENT |
< 100 | Outbound connect hangs — network path issue |
SYN-RECV |
< 50 | Accept queue pressure or scan |
FIN-WAIT-* |
Small numbers | Normal close in progress |
The rule of thumb: CLOSE-WAIT > 100 is a bug on the local side, SYN-SENT > 100 is a bug on the network path, TIME-WAIT > 10k is churn.
Command 3: Kernel TCP Counters
What to watch:
- TCPRetransFail growing → packets dropped on the path
- ListenOverflows growing → accept queue full, real SYN drops
- ListenDrops growing → SYN cookies in use or backlog exceeded
If all three are flat, TCP is not the problem and you should look elsewhere.
Scenario 1: TIME-WAIT Flood (35% of production cases)
Symptom match: ss -s shows thousands of timewait. ss -tn state time-wait | wc -l returns 5000+. CPU spikes correlate with connection close rate.
Root cause: A server-side application closes connections first (typically a web server or reverse proxy after an HTTP response). Every close enters TIME-WAIT for 60 seconds (2× MSL). Under high request rates this exhausts the ephemeral port range.
Diagnosis:
# Count TIME-WAIT sockets
ss -tn state time-wait | wc -l
# Who's generating them? Group by peer
ss -tn state time-wait | awk '{print $5}' | sort | uniq -c | sort -rn | head
# Are we running out of ephemeral ports?
ss -s | grep -i 'timewait'
cat /proc/sys/net/ipv4/ip_local_port_range
Fix — server-side only:
# /etc/sysctl.d/90-tcp.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.ip_local_port_range = 1024 65535
Fix — reduce churn at the source (better than tuning):
If the TIME-WAIT comes from Nginx acting as a client to upstreams, enable upstream keepalive (see Nginx 502/504 troubleshooting for the full config):
upstream backend {
server 127.0.0.1:8080;
keepalive 32;
}
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://backend;
}
Keepalive is the correct fix. tcp_tw_reuse is the emergency valve.
Verification: TIME-WAIT count stays below 500 for 10 minutes under load, ss -s shows stable timewait number.
Pitfall: Never enable tcp_tw_reuse on a client-side connection path (i.e., a workload that opens many outbound connections). It was designed for the server side and can cause correctness bugs under NAT on the client side. If in doubt, use keepalive instead.
Scenario 2: RST Storm (25% of production cases)
Symptom match: Connections randomly dropping with ECONNRESET in app logs. tcpdump shows RST packets. Some workloads (e.g., long HTTP/1.1 connections to a load balancer) fail intermittently.
Root cause: One side of the TCP connection is closed while the other side is still sending data. The closed side responds with RST. In production this is almost always one of:
- Load balancer idle timeout shorter than app idle time — most common. The LB closed, the app sends on the next tick, and gets an RST.
- App socket idle timeout shorter than peer's — symmetric to above.
- Application process crash — kernel sends RST on behalf of the dead process.
- Firewall with short idle timeout — middlebox is silently pruning idle TCP sessions.
Diagnosis:
# Live RST capture
tcpdump -nn -i any 'tcp[tcpflags] & tcp-rst != 0'
# Count RST rate over time
watch -n1 'netstat -s | grep -i reset'
# For a specific connection pair
tcpdump -nn -i any host 10.0.0.5 and port 8080 and 'tcp[tcpflags] & tcp-rst != 0'
Fix — socket-level keepalive on both ends:
export TCP_KEEPIDLE=60 # Start probing after 60s idle
export TCP_KEEPINTVL=10 # Probe every 10s
export TCP_KEEPCNT=3 # Give up after 3 failed probes (60+30 = 90s total)
Or via code — Python:
import socket
s = socket.create_connection(('backend:8080',))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
Fix — align LB timeout with app timeout:
AWS ALB default idle timeout is 60s. If your upstream app sends data every 90s, you'll get RST storms. Either lower the LB idle timeout below your app's send interval, or send TCP keepalives.
Verification: RST rate from netstat -s becomes flat over 5 minutes under steady load.
Scenario 3: SYN Half-Open (20% of production cases)
Symptom match: ss -tn state syn-recv | wc -l returns thousands. netstat -s | grep -i listen shows ListenOverflows growing. App can accept connections but new client connections time out.
Root cause: The server's SYN receive queue is full. Incoming SYNs are dropped, and the client retransmits. Two sub-cases:
- SYN flood attack — many SYNs from spoofed sources. Kernel's SYN cookies (
net.ipv4.tcp_syncookies, on by default) usually absorb this. - Legitimate burst — real clients sending real SYNs, but the accept queue is too small.
Diagnosis:
# Are we seeing listen overflows?
netstat -s | grep -i listen
# SYN-RECV count
ss -tn state syn-recv | wc -l
# Backlog vs current accept queue
ss -lnt | awk '$4 ~ /LISTEN/' | awk '{print $2, $3}'
# Recv-Q = current accept queue length, Send-Q = backlog limit
Fix — raise backlog and queues:
# /etc/sysctl.d/90-tcp.conf
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_tw_buckets = 2000000
The app must also use a large listen() backlog:
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, &addr, sizeof(addr));
listen(server_fd, 8192); // Match somaxconn, not the default 128
Note: listen() backlog is capped by somaxconn. Raising one without the other does nothing.
Fix — SYN flood protection:
If SYN-RECV is dominated by one IP, it's a scan or flood. Enable SYN cookies (default), consider tcp_synack_retries:
Verification: ListenOverflows stops growing, SYN-RECV count stabilizes under burst.
Scenario 4: Retransmission Spike (15% of production cases)
Symptom match: Slow requests, timeouts, but no RSTs. netstat -s | grep -i retrans shows TCPRetransFail climbing.
Root cause: Packets dropped on the path — either wire-level loss (cable, switch, Wi-Fi) or congestion-induced drops. TCP's retransmission algorithm kicks in, but if the loss rate exceeds a threshold the effective throughput collapses.
Diagnosis:
# Cumulative retransmission count
netstat -s | grep -i retrans
# Per-connection retransmission (modern kernels, ss >= 2.19)
ss -ti 10.0.0.5:8080
# Check link errors
ethtool -S eth0 | grep -iE 'drop|error|crc'
# Is it one direction or bidirectional?
tcpdump -nn -i any host 10.0.0.5
# Look for duplicate seq numbers
Fix — kernel tuning helps slightly, doesn't fix the root cause:
# More aggressive retransmission
net.ipv4.tcp_retries1 = 3
net.ipv4.tcp_retries2 = 15
net.ipv4.tcp_syn_retries = 3
net.ipv4.tcp_synack_retries = 3
net.ipv4.tcp_rmem = 4096 87380 16777216
The kernel tuning above doesn't fix packet loss. If retransmissions are real, find and fix the lossy segment:
# Where's the loss happening? Probe hop by hop
mtr -rwc 100 10.0.0.5
# Look for loss at a specific hop
# Or use IP fragmentation detection
ping -M do -s 1472 host # MTU probing
If mtr shows loss at hop 3 but not hop 2 or 4, that switch or router is the problem — file a ticket with your network team.
Verification: nstat -az | grep -i retrans shows flat count over 5 minutes. mtr loss stabilizes at baseline.
Closing the Loop: TCP Cheat Sheet
20 ss / netstat / sysctl One-Liners
| # | Command | What it tells you |
|---|---|---|
| 1 | ss -s |
Overall TCP summary (states, timewait, orphaned) |
| 2 | ss -tn |
All TCP connections with state |
| 3 | ss -tn state time-wait |
Only TIME-WAIT sockets |
| 4 | ss -tn state close-wait |
Local app not closing — always a bug |
| 5 | ss -tn state syn-sent |
Outbound connects that haven't completed |
| 6 | ss -tn state syn-recv |
Inbound half-open connections |
| 7 | ss -lnt |
Listening sockets with Recv-Q / Send-Q |
| 8 | ss -tnp |
TCP connections with owning process |
| 9 | ss -tni |
TCP connections with detailed info (retrans, cwnd) |
| 10 | ss -tn dst 10.0.0.5 |
Connections to a specific host |
| 11 | ss -tn sport = :8080 |
Connections on a specific port |
| 12 | ss -tn \| awk '{print $1}' \| sort \| uniq -c |
State distribution |
| 13 | ss -tn state time-wait \| wc -l |
TIME-WAIT count |
| 14 | netstat -s \| grep -iE 'retrans\|reset\|listen\|overflow' |
Kernel TCP counters |
| 15 | nstat -az \| grep -i retrans |
Per-nstat retrans counters (delta-friendly) |
| 16 | cat /proc/net/tcp |
Raw TCP table |
| 17 | sysctl net.ipv4.tcp_tw_reuse |
Current TIME-WAIT reuse setting |
| 18 | sysctl net.core.somaxconn |
Current accept queue cap |
| 19 | ethtool -S eth0 \| grep -iE 'drop\|error\|crc' |
NIC error counters |
| 20 | mtr -rwc 100 host |
Loss by hop |
6 tcpdump / sysctl / Config Snippets
1. tcpdump: only RST packets
2. tcpdump: SYN without ACK (handshake attempts)
3. tcpdump: zero window (receiver backpressure)
4. sysctl: production server baseline
# /etc/sysctl.d/90-tcp.conf
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.ip_local_port_range = 1024 65535
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 2
5. Socket-level keepalive (Python)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
6. listen() backlog must match somaxconn
When You're Still Stuck
If you've covered all four scenarios and TCP still looks wrong, drop to the packet layer:
- Packet capture:
tcpdump -nn -i any -w /tmp/conn.pcapfor the duration of the issue. Open in Wireshark (see Wireshark basics for filters). - Look for 3 signatures: (a) unacknowledged data → retransmissions, (b) RST in one direction → app closed early, (c) duplicate ACKs → loss on path.
- Compare to a known-good path: same workload, different IP, same port. If one works and the other doesn't, it's the path.
502 vs 504 — Where the Boundary Is
If you came here from Nginx 502/504 issues: Nginx reports what it saw on its side of the socket. Your Nginx may report upstream timed out while the real problem is a retransmission storm on the app side. Or Nginx may report connection refused while the real problem is the upstream's accept queue is full. Nginx error.log tells you the symptom; ss and netstat tell you the state; tcpdump tells you the packet-level truth. Read them in that order.
Related Articles
- Nginx Reverse Proxy 502/504 Decision Tree — application layer above TCP
- Wireshark Basics — packet-level analysis after this fails
- TCP/IP State Machine — reference for what each state means
Authoritative References
- RFC 793 — Transmission Control Protocol — the original TCP spec
- RFC 879 — Transmission Control Protocol — corrected version, still normative
- RFC 6335 — Well-Known Ports — port registration policy
- man7.org:
ss(8)— socket statistics command - man7.org:
netstat(8)— network statistics command - man7.org:
tcpdump(1)— packet capture
Written for ops.jiunotes.top. Original work based on production incident experience. Not copied from any third-party blog. CC BY-NC-SA 4.0.