Skip to main content

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

StateMeaning
CLOSEDno connection exists - the starting and ending state
LISTENserver socket waiting for an incoming SYN
SYN_SENTclient sent a SYN, waiting for SYN-ACK
SYN_RCVDserver received a SYN, sent its own SYN-ACK, waiting for the final ACK
ESTABLISHEDhandshake complete - normal data transfer in both directions
FIN_WAIT_1this side sent a FIN and is waiting for it to be acknowledged
FIN_WAIT_2this side's FIN was acked; waiting for the peer's own FIN
CLOSE_WAITpeer's FIN was received and acked; this side has not yet called close()
LAST_ACKthis side sent its own FIN (after CLOSE_WAIT) and is waiting for the final ACK
CLOSINGboth sides sent FIN at roughly the same time (simultaneous close), waiting for the peer's ACK
TIME_WAITactive closer's final state - waits 2*MSL before fully closing
CLOSEDconnection 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

$ ss -tan
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 0.0.0.0:443 0.0.0.0:*
ESTAB 0 0 10.0.0.5:443 203.0.113.9:51422
CLOSE-WAIT 1 0 10.0.0.5:443 203.0.113.44:60310
TIME-WAIT 0 0 10.0.0.5:52344 198.51.100.7:443

A quick census of connection health is one command away:

$ ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn

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 than TIME_WAIT entries expire, it runs out of source ports to use for new connections and connect() starts failing with EADDRNOTAVAIL.
  • Accept-side pile-up. A very busy server can accumulate enormous numbers of TIME_WAIT sockets, consuming memory and file-descriptor-adjacent kernel state even though the connections are functionally dead.
MitigationWhat it actually does
SO_REUSEADDRlets 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-alivereuses 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 rangemore 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 does not skip TIME_WAIT

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.

info

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.