TCP States in Practice
The TCP Handshake walked through the three-way open and four-way close.
Underneath that walkthrough is a formal state machine every TCP connection
obeys, and that state machine is directly visible on a running system - it is
what ss and netstat print in their State column. This lesson is the
reference table for those states and the production issue (TIME_WAIT
exhaustion) that shows up when connection churn is high.
The full state table
| State | Meaning |
|---|---|
| CLOSED | no connection exists - the starting and ending state |
| LISTEN | server socket waiting for an incoming SYN |
| SYN_SENT | client sent a SYN, waiting for SYN-ACK |
| SYN_RCVD | server received a SYN, sent its own SYN-ACK, waiting for the final ACK |
| ESTABLISHED | handshake complete - normal data transfer in both directions |
| FIN_WAIT_1 | this side sent a FIN and is waiting for it to be acknowledged |
| FIN_WAIT_2 | this side's FIN was acked; waiting for the peer's own FIN |
| CLOSE_WAIT | peer's FIN was received and acked; this side has not yet called close() |
| LAST_ACK | this side sent its own FIN (after CLOSE_WAIT) and is waiting for the final ACK |
| CLOSING | both sides sent FIN at roughly the same time (simultaneous close), waiting for the peer's ACK |
| TIME_WAIT | active closer's final state - waits 2*MSL before fully closing |
| CLOSED | connection fully torn down, no state remains |
Two states are easy to mix up: CLOSE_WAIT is not something TCP resolves by
itself - it means the local application has not yet called close() on a
socket whose peer already sent FIN. A socket stuck in CLOSE_WAIT is almost
always an application bug (a leaked file descriptor, a handler that never
closes the connection), not a network problem.
Reading ss -tan / netstat -tan
A quick census of connection health is one command away:
A pile of CLOSE-WAIT entries that never shrinks points at an application
that forgets to close sockets. A large, steady TIME-WAIT count on a busy
server is usually normal - see below for when it is not.
TIME_WAIT exhaustion
TIME_WAIT exists for a good reason (drain duplicate segments, guarantee the
final ACK was seen - see The TCP Handshake), but it holds a (local IP, local port, remote IP, remote port) tuple pinned for 2*MSL, commonly 60
seconds total on Linux (2 * 30s). Under high connection churn - a server or
a load-balanced client opening and closing many short-lived connections per
second, notably to the same remote IP:port - two failure modes emerge:
- Ephemeral port exhaustion. A client machine has roughly 28,000-60,000
ephemeral ports available (
/proc/sys/net/ipv4/ip_local_port_range). If it opens new short-lived connections to the same destination faster thanTIME_WAITentries expire, it runs out of source ports to use for new connections andconnect()starts failing withEADDRNOTAVAIL. - Accept-side pile-up. A very busy server can accumulate enormous numbers
of
TIME_WAITsockets, consuming memory and file-descriptor-adjacent kernel state even though the connections are functionally dead.
| Mitigation | What it actually does |
|---|---|
| SO_REUSEADDR | lets a new socket bind a local address/port that has a lingering TIME_WAIT entry from a previous connection with a different peer - mainly fixes 'address already in use' on server restart, not exhaustion |
| Connection pooling / keep-alive | reuses existing connections instead of opening a new one per request, so TIME_WAIT is never generated at high volume in the first place |
| Widen the ephemeral port range | more ports before exhaustion, buys headroom but does not address the underlying churn |
| tcp_tw_reuse (Linux) | allows reusing a TIME_WAIT socket for a new outgoing connection when safe, based on timestamps - helps clients specifically |
SO_REUSEADDR is widely misremembered as "disables TIME_WAIT." It does not -
it only relaxes the kernel's refusal to bind a port that still has an old
TIME_WAIT socket attached, which matters when a server process restarts and
needs its listening port back immediately. It does nothing for a client
machine burning through ephemeral ports against one destination; the real fix
there is fewer, longer-lived connections (pooling and keep-alive), not a
socket option.
Seeing TIME_WAIT exhaustion in production is a signal to stop opening a new
short-lived connection per request and start reusing connections - HTTP
keep-alive, gRPC channels, database connection pools. The socket-option
workarounds reduce symptoms; connection reuse removes the cause.