25. Connection teardown, half-close, and TIME_WAIT
25.1 The graceful four-way close
TCP closes each direction independently (it's two simplex streams):
Client Server
| FIN, ACK seq=u ->| "I'm done sending"
|<- ACK ack=u+1 | (server may still send data)
| ... server sends remaining response ... |
|<- FIN, ACK seq=v | "now I'm done too"
| ACK ack=v+1 ->|
| |
| TIME_WAIT (2*MSL) | CLOSED
| then CLOSED |
Often it's only three packets because the server piggybacks its FIN on the
ACK of the client's FIN (FIN, ACK in one segment).
25.2 Half-close
shutdown(fd, SHUT_WR) sends a FIN but keeps reading. Used by e.g. old HTTP
clients: "I've finished sending my request (FIN), now stream me the response."
The peer sees EOF on read but can still write. close() shuts down both
directions.
25.3 TIME_WAIT — why it exists and why it's usually fine
The side that sends the last ACK (usually whoever initiated the close) enters
TIME_WAIT for 2×MSL (Maximum Segment Lifetime; Linux hardcodes MSL=60 s,
so TIME_WAIT ≈ 60 s; the sysctl net.ipv4.tcp_fin_timeout controls FIN_WAIT_2,
not this). Purposes:
- Absorb stragglers: a delayed retransmission of the peer's FIN can still arrive; TIME_WAIT is there to ACK it instead of replying RST.
- Prevent 4-tuple reuse confusion: stops a new connection with the same 4-tuple from receiving old delayed segments.
TIME_WAIT sockets hold almost no memory and don't hold a file descriptor. On a client making many outbound connections they can exhaust the ephemeral port range to one destination (~28k / 60s ≈ 460 conn/s ceiling). On a server they are generally harmless (the server side rarely initiates close, and when it does, its "port" is fixed at 443 while the tuple varies by client).
25.4 Managing TIME_WAIT correctly
- Best fix: don't churn connections — use keep-alive / connection pools so you open few and reuse them.
net.ipv4.tcp_tw_reuse = 1(client-side, outbound): lets a new outbound connection safely reuse a TIME_WAIT tuple when the TCP timestamp proves the old segments are older. Safe and recommended for busy clients.tcp_tw_recycle: removed in Linux 4.12. It broke NAT clients horribly (rejected connections from hosts sharing a NAT whose timestamps went "backwards"). Never enable it on anything where it still exists.- Widen
ip_local_port_range, add source IPs, or spread across more destination IPs/ports. SO_LINGER {1,0}makesclose()send an RST instead of FIN → no TIME_WAIT, but you lose any unacked data and the peer sees a hard reset. Only for controlled cases (e.g. deliberately dumping idle connections).
25.5 CLOSE_WAIT — the bug indicator
If you see many sockets stuck in CLOSE_WAIT, that's your application's bug:
the peer sent FIN, the kernel ACKed it, but your code never called close() on
that fd. They pile up, leak file descriptors, and eventually accept() fails
with EMFILE. Fix the code path that forgets to close (error branches,
exceptions, missing defer conn.Close()).
25.6 RST — the abortive close
A RST tears the connection down immediately, discards buffered data, and the peer
gets ECONNRESET ("Connection reset by peer") on its next read/write. Causes:
- App set
SO_LINGER 0and closed. - Data arrived for a socket the app already closed.
- Segment arrived that doesn't match any connection (e.g. after one side rebooted, or a NAT mapping expired mid-connection).
- A firewall/IPS forged a RST to block the flow (Great Firewall, some corporate DLP, old carrier "TCP optimizers").
25.7 War story
An API gateway leaked file descriptors and fell over every ~6 hours with
too many open files. ss -tan state close-wait | wc -l grew monotonically.
The upstream HTTP client returned a response object but, on a specific 204
no-content path, never closed the body reader, so the underlying socket stayed
in CLOSE_WAIT. One-line fix (close the body in a finally/defer).
Lesson: rising CLOSE_WAIT = local missing close(); rising TIME_WAIT = you're
opening too many connections.
Try it yourself
ss -tan state time-wait | wc -l
ss -tan state close-wait | wc -l
ss -tan state fin-wait-1 state fin-wait-2
sysctl net.ipv4.tcp_tw_reuse net.ipv4.tcp_fin_timeout
sudo tcpdump -i any -n 'tcp[tcpflags] & (tcp-fin|tcp-rst) != 0' -c20