The Connection Lifecycle: Close, Reset, TIME_WAIT, CLOSE_WAIT
A TCP connection is a state machine on both ends: established by the handshake, torn down by a FIN in each direction, aborted by RST — and the side that closes first sits in TIME_WAIT for a minute holding the port pair, while a side that never calls close() sits in CLOSE_WAIT forever; these states explain ECONNRESET, ephemeral-port exhaustion, descriptor leaks, and why many short connections are expensive.
The problem
The state machine
Each end of a connection is in one of eleven states. The handshake (The Three-Way Handshake) moves the client through SYN_SENT and the server through LISTEN and SYN_RECV to ESTABLISHED on both. Data flows in ESTABLISHED, which is the state a healthy long-lived connection lives in. Closing takes more states than opening, because each direction of the full-duplex stream is closed separately and either side may go first.
The side that calls close() first is the active closer: it sends FIN and goes to FIN_WAIT_1, then on the ACK to FIN_WAIT_2, then on the peer’s FIN to TIME_WAIT, where it lingers before CLOSED. The passive closer receives the FIN, ACKs it, and enters CLOSE_WAIT — a state that ends only when *its application* calls close(), sending its own FIN and moving to LAST_ACK, then CLOSED on the final ACK. Both FINs and both ACKs are needed; this is the four-way close (often three packets, since the passive side’s ACK and FIN can be combined).
ESTABLISHED ESTABLISHED | close() | | ---- FIN, seq=x -------------------------> | FIN_WAIT_1 CLOSE_WAIT (app must still call close()) | <--- ACK x+1 ----------------------------- | FIN_WAIT_2 | ... app may keep sending data ... | | close() | <--- FIN, seq=y -------------------------- | TIME_WAIT LAST_ACK | ---- ACK y+1 ----------------------------> | | (2 x MSL = 60 s on Linux) CLOSED CLOSED
Graceful close and half-close
FIN means "I will send no more data". It does not mean "I will receive no more". After sending FIN a side can keep reading, and the peer can keep writing, for as long as it likes — the half-closed state (FIN_WAIT_2 on one side, CLOSE_WAIT on the other). shutdown(fd, SHUT_WR) sends FIN without giving up the descriptor and is how a client says "that was the whole request, now I will wait for your whole response": the server reads to end-of-stream, knows the request is complete, writes the response, and closes. close() sends FIN *and* drops the descriptor; if unread data remains in the receive buffer when you call it, the kernel sends RST instead of FIN, which is one of the ways a well-meaning close turns into a reset at the other end.
Data sent before the FIN is still delivered and still retransmitted if lost; FIN itself occupies a sequence number and is acknowledged like a byte. A graceful close is therefore reliable: when the final ACK arrives, both sides know everything got through. What close() does *not* do is wait for that — it returns immediately, and the kernel finishes the close in the background — unless SO_LINGER is set to make it block.
1s.sendall(request_bytes)2s.shutdown(socket.SHUT_WR) # FIN: "no more from me"; the peer's recv() returns b"" at EOF3chunks = []4while (chunk := s.recv(65536)): # keep reading until the peer sends its FIN5 chunks.append(chunk)6s.close() # our side: FIN_WAIT_2 -> TIME_WAIT after their FINRST, and what ECONNRESET means
A reset is the abrupt path. RST is sent when a segment arrives for a connection that does not exist: a SYN to a port with no listener (that is ECONNREFUSED), data to a socket that has been closed and forgotten, anything to a host that rebooted and lost all its state. It is also sent deliberately: close() with unread data, SO_LINGER with a zero timeout, and by middleboxes — load balancers ending idle connections, firewalls terminating flows they no longer recognise, some stateful NATs whose mapping expired (NAT: Many Private Hosts Behind One Public Address). A reset is not acknowledged and delivers nothing; whatever was in flight is gone.
On the receiving side, the next read() or write() fails with `ECONNRESET` ("Connection reset by peer"). It means, precisely, "the other end, or something in the middle, told me this connection no longer exists". It is almost never a bug in the code that observes it. Common causes in order of likelihood: the peer process crashed or was restarted; a load balancer or NAT idle timeout fired on a connection your pool believed was alive; the peer closed with unread data because you sent more than it expected; the peer rebooted. A write to a connection the peer has already closed produces `EPIPE` (and SIGPIPE, which kills the process by default — every network daemon ignores that signal for this reason) rather than a reset. A read on a gracefully closed connection returns zero bytes, not an error; only RST produces ECONNRESET.
ECONNREFUSED: RST in reply to SYN — nothing listening.ECONNRESET: RST on an established connection — peer gone, middlebox timeout, or close-with-unread-data.EPIPE/SIGPIPE: writing after the peer’s FIN.- read() → 0 bytes: graceful EOF, the peer’s FIN. Not an error.
TIME_WAIT: who holds it and why it costs ports
After the active closer sends the final ACK it cannot simply forget the connection, for two reasons. First, that ACK may be lost; the peer would then retransmit its FIN, and someone must be there to ACK it again — otherwise the peer is stuck in LAST_ACK. Second, segments from this connection may still be wandering in the network; if the same 4-tuple were reused immediately, an old segment could arrive inside the new connection with a plausible sequence number and corrupt it. So the active closer waits 2 × MSL — twice the maximum segment lifetime, which is a fixed 60 seconds on Linux — in TIME_WAIT before releasing the 4-tuple. It is a fundamental property of the design, not a defect.
The cost lands on whoever closes first. Servers that close first (HTTP servers usually do after a response without keep-alive) accumulate TIME_WAIT entries, which are small (a few hundred bytes each) and mostly harmless. Clients that close first are in a different position: each closed connection to a given server holds one of the client’s ephemeral ports for 60 seconds, and Linux has 28,232 of them by default (net.ipv4.ip_local_port_range = 32768 60999). A client opening and closing connections to *one* destination faster than 28,232 / 60 s ≈ 470 per second runs out of ports and connect() fails with EADDRNOTAVAIL — the ephemeral-port-exhaustion challenge. Proxies, sidecars and services that "just open a new connection per request" to a database hit this in production and never in tests.
Mitigations, in order of preference: do not open that many connections (pooling, keep-alive); let the *server* close first so the TIME_WAIT lands on the side with a fixed port; widen the port range; and net.ipv4.tcp_tw_reuse = 1, which lets the kernel reuse a TIME_WAIT 4-tuple for a new *outgoing* connection when timestamps prove the old segments cannot be confused with it. tcp_tw_recycle was a trap and has been removed. On the server side, SO_REUSEADDR is what lets a restarted daemon bind its port while old connections to it are still in TIME_WAIT; without it, the restart fails with "Address already in use" for a minute.
CLOSE_WAIT: the application forgot
CLOSE_WAIT is the passive closer’s state after receiving FIN and before calling close() itself. The kernel has acknowledged the peer’s FIN, the peer is sitting in FIN_WAIT_2 waiting for *our* FIN, and the only thing that will send it is the application closing the descriptor. There is no timer on the CLOSE_WAIT side. If the application never closes — because an exception skipped the cleanup, because a connection object was dropped without close(), because a pool handed out a connection and lost track of it — the socket stays in CLOSE_WAIT *forever*, holding a file descriptor and its buffers.
A growing count of CLOSE_WAIT is therefore a descriptor leak, and it is unambiguous: the peer has closed, and this process has not. Under load it ends in EMFILE ("Too many open files") when the process hits its descriptor limit (File Descriptors, fd-leak-under-load), at which point accept() starts failing and the service is down while the CPU is idle. The peer side, FIN_WAIT_2, does have a timer on Linux (tcp_fin_timeout, 60 s) when the socket has been fully closed, so the leak shows on only one end — the guilty one.
Why many short connections are expensive
Add up what a fresh connection pays. One round trip for the handshake (The Three-Way Handshake); one more for TLS 1.3 (The TLS Handshake); a congestion window that starts at ten segments and must ramp up (Congestion Control: Protecting the Network); kernel state on both ends for the connection’s lifetime plus 60 seconds of TIME_WAIT; and a descriptor and a pair of buffers each. For a request that transfers a few kilobytes, setup is the majority of the latency and the whole of the port budget. This is the arithmetic behind HTTP Keep-Alive and Connection Reuse, behind every Connection Pooling library, and behind HTTP/2 and HTTP/3 putting everything on one long-lived connection.
The state table is also your best cheap diagnostic. A histogram of states from ss on a busy host is a one-line health check: mostly ESTABLISHED is healthy; a large TIME_WAIT count is churn (find out who is not reusing connections); a large CLOSE_WAIT count is a leak in *this* process; many SYN_RECV is a flood or a slow accept(); many FIN_WAIT_2 with the peer in CLOSE_WAIT is the peer leaking.
$ ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
18342 TIME-WAIT # churn: someone opens a connection per request
1205 ESTAB
611 CLOSE-WAIT # leak: this process received FINs and never closed
24 LISTEN
9 SYN-RECV
3 FIN-WAIT-2
$ ss -tan state close-wait | awk '{print $4}' | cut -d: -f2 | sort | uniq -c
611 8080 # all on the app's port: it is our app that forgot to closeKey points
- Close is two FINs and two ACKs; each direction closes separately, and half-close (shutdown(SHUT_WR)) is a legitimate way to say "request complete".
- RST aborts without acknowledgment; ECONNRESET on read/write means the peer or a middlebox says the connection is gone — usually a crash, an idle timeout, or close-with-unread-data.
- The active closer holds TIME_WAIT for 2×MSL (60 s on Linux) so a late ACK can be re-sent and stale segments cannot enter a reused 4-tuple.
- Clients that close first burn an ephemeral port per connection for 60 s: ~470 connections/s to one destination exhausts the default range.
- CLOSE_WAIT has no timer; a growing count is this process failing to close() sockets whose peer has already left — a descriptor leak ending in EMFILE.
- Short connections pay handshake RTTs, TLS, slow start, TIME_WAIT and descriptors; keep-alive and pooling exist to pay once.
ss -tan | awk | sort | uniq -cis the one-line state histogram; each state that dominates has a different owner.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does closing take more messages than opening?
Because a full-duplex stream is two streams, and either side may finish sending while still needing to receive. Two independent FINs, each acknowledged, is the minimum that lets both sides finish on their own schedule.
▸Why hold TIME_WAIT for a full minute?
Segments can be delayed in the network for up to a maximum segment lifetime; waiting twice that guarantees that nothing from the old connection can arrive inside a new one on the same ports, and that the peer’s retransmitted FIN can still be answered. The minute is the price of reusing 4-tuples safely.
▸Why does the passive side not time out of CLOSE_WAIT?
Because the application may legitimately still have data to send after the peer stops; the kernel cannot know when it is done. The only correct signal is close(), and if the application never sends it, the kernel is right to wait.
The TCP state machine
both endpoints CLOSED
$ ss -tan | awk '{print $1}' | sort | uniq -c
812 ESTAB
1490 TIME-WAIT
0 CLOSE-WAIT
0 LAST-ACK
12 LISTENHow it fails
What the failure looks like from inside real software.
connect()failing withEADDRNOTAVAILon a proxy or sidecar under load: ephemeral ports exhausted by TIME_WAIT from a connection-per-request pattern to one upstream.- Hundreds of CLOSE_WAIT sockets on an HTTP server after a day, then
EMFILEandaccept()failures: a code path that returns without closing the client socket on error. ECONNRESETon the first request after 5 minutes of idle: the load balancer’s idle timeout is shorter than the pool’s; the pool hands out dead connections.- A daemon that cannot restart for 60 seconds with "Address already in use": it did not set
SO_REUSEADDRand its old connections are in TIME_WAIT. SIGPIPEkilling a worker process silently: it wrote to a connection the client had already closed and never ignored the signal.- A client stuck in FIN_WAIT_2 for a minute per connection while the server never sends its FIN: the server is the one leaking, and the client’s
tcp_fin_timeoutis what eventually cleans up its side.