Skip to content

Connection teardown (FIN/ACK)

Opening a TCP connection is symmetric and quick — three messages, one round trip, both sides land in ESTABLISHED at the same moment. Closing one is neither. TCP treats each direction of a connection as independently closable, which means teardown is really two separate shutdowns happening one after another, and the side that closes first ends up parked in a waiting state for a fixed period afterward, for reasons that have nothing to do with either host being slow.

Why closing needs four messages, not two

A TCP connection carries two independent byte streams, one in each direction, established together by the three-way handshake but not required to end together. An application can legitimately be done sending while it's still willing to receive — think of a client that has uploaded a complete file and now just wants the server's confirmation back. TCP's teardown reflects that: each side sends its own FIN when it has no more data to send, and each FIN gets its own ACK, independent of the other direction's FIN.

Client                                        Server
  |                                              |
  |  FIN  seq=m                                  |
  | -------------------------------------------> |
  |                                    [CLOSE-WAIT]
  |  ACK  ack=m+1                                |
  | <------------------------------------------- |
[FIN-WAIT-2]                                     |
  |                                               |
  |  FIN  seq=n                                  |
  | <------------------------------------------- |
  |  ACK  ack=n+1                                |
  | -------------------------------------------> |
[TIME-WAIT]                                [CLOSED]
  |
  | (waits ~60s, then closes)
  v
[CLOSED]

Read it as two half-closes, not one symmetric handshake:

  1. The client, done sending, sends FIN, seq=m. This consumes one sequence number, exactly like a SYN did during the handshake, which is why the server's acknowledgment is ack=m+1.
  2. The server acknowledges immediately and moves to CLOSE-WAIT — it has been told the client won't send more, but the server itself may still have data left to send, so it doesn't send its own FIN yet. The client, having received the ACK for its FIN but not yet the server's, sits in FIN-WAIT-2.
  3. Once the server's application actually finishes sending (and calls close() on its own socket), the server sends its own FIN, seq=n.
  4. The client acknowledges with ack=n+1 and enters TIME-WAIT — not CLOSED — while the server, having received that final ACK, moves straight to CLOSED.

That asymmetry in the last step — one side reaches TIME-WAIT, the other reaches CLOSED directly — is the detail worth sitting with, because it's the part Introduction to TCP flagged as needing its own explanation.

Why TIME-WAIT exists

Whichever side sends the last ACK of the teardown — acknowledging the other side's FIN — has no way to know whether that ACK actually arrived. If it's lost, the other side's FIN will time out and get retransmitted, and something has to still be listening on that exact socket to acknowledge it a second time. If the local socket had already moved straight to CLOSED and its port were immediately reused for a brand-new, unrelated connection, that retransmitted FIN would arrive at a socket that has no idea what it's talking about — at best confusing, at worst a way for a genuinely late-arriving segment from the old connection to be misdelivered into the new one and misread as legitimate data.

TIME-WAIT is the fix: the side that sent the last ACK keeps the connection's four-tuple reserved for a period long enough that any segment still legitimately in flight from the old connection is guaranteed to have expired on its own, rather than risk it colliding with a new connection that happens to reuse the same source and destination ports. RFC 9293 ties this directly to the Maximum Segment Lifetime (MSL) — the longest a segment is assumed able to survive in the network before being discarded — and specifies the TIME-WAIT duration as 2×MSL. That's a specification, not a universal constant: it's a kernel setting, not a value TCP negotiates on the wire, so different operating systems implement it differently. Linux hardcodes a fixed 60-second TIME-WAIT period (TCP_TIMEWAIT_LEN) regardless of MSL, which is why ss -tan state time-wait, run against a Linux host, shows sockets clearing out after about a minute — not the 4-minute (2×MSL with a 2-minute MSL) figure some older BSD-derived documentation still quotes.

Watching it happen

ss -tan state time-wait
State      Recv-Q Send-Q  Local Address:Port    Peer Address:Port
TIME-WAIT  0      0       10.0.5.12:53211        93.184.216.34:443
TIME-WAIT  0      0       10.0.5.12:53244        93.184.216.34:443

Each of these sockets is fully done, in the sense that no more data will ever be sent or received on it — but the kernel is still holding the source port associated with it, unavailable for reuse, until the TIME-WAIT timer expires (60 seconds on Linux, as noted above). On a machine making many short-lived outbound connections to the same destination, TIME-WAIT sockets accumulating faster than they expire is exactly the mechanism behind ephemeral port exhaustion, covered from the port-allocation side in Dynamic port range — this article is the other half of that same story, explaining why the closed connection still occupies a port instead of releasing it immediately.

The abrupt alternative: RST

Not every connection ends with a clean FIN/ACK exchange. A RST (reset) tears a connection down immediately, with no negotiation and no TIME-WAIT on the side that sent it — it's a statement that the connection is invalid or being abandoned right now, not a request to wind it down gracefully. A RST shows up when: an application crashes and its socket is force-closed by the OS with unread data still in the receive buffer; a firewall or load balancer decides to terminate a connection it considers stale or policy-violating; or a segment arrives for a connection the receiving host has no record of at all, as the three-way handshake article showed for a SYN to a closed port. From an application's perspective, a RST usually surfaces as a "connection reset by peer" error rather than a clean end-of-stream — a meaningfully different failure mode from a normal close, and one worth distinguishing when reading logs, since "reset" points at an abrupt termination somewhere, while a clean FIN-based close rarely produces an application-visible error at all.

Practical scenario: a load balancer that's the one closing connections

A backend service periodically logs ECONNRESET errors from clients, even though the service itself never explicitly closes those connections and isn't crashing. The clients report their requests as failed outright, not merely slow.

A capture on the backend host during a failure shows the pattern clearly:

sudo tcpdump -i eth0 -n host 10.0.8.30 and port 8080 -c 6
11:02:40.010112 IP 10.0.8.30.44210 > 10.0.5.15.8080: Flags [P.], seq 8801:9200, ack 4410, win 502, length 399
11:02:40.010440 IP 10.0.5.15.8080 > 10.0.8.30.44210: Flags [.], ack 9200, win 700, length 0
11:03:10.221009 IP 10.0.8.30.44210 > 10.0.5.15.8080: Flags [R], seq 9200, win 0, length 0

The backend acknowledges the request normally, and thirty seconds pass with no further activity before a bare RST arrives — no FIN beforehand, and the reset comes from the client side of the connection (10.0.8.30), which in this deployment is actually a load balancer sitting in front of the real end users, not the backend itself. That thirty-second gap, followed immediately by a reset rather than a graceful close, is the signature of an idle connection timeout on the load balancer: it's configured to forcibly terminate any backend connection that's been quiet for 30 seconds, and it does so with a RST rather than a FIN/ACK exchange, which is why the backend sees an abrupt reset instead of a normal teardown.

The fix isn't on the backend at all — it's aligning the backend application's own idle-connection or keep-alive timeout to be shorter than the load balancer's, so the backend proactively closes idle connections cleanly before the load balancer ever has reason to reset them. Getting the ordering backwards (backend timeout longer than the load balancer's) guarantees the load balancer will always win that race, and every affected request looks like an unexplained reset until someone checks the actual timeout values on both sides.

Practice exercises

  1. Using ss -tan, find any sockets on a machine you have access to in TIME-WAIT. Confirm they've genuinely finished (no Send-Q or Recv-Q bytes pending) and explain what event over the past two minutes on that host most likely created each one.
  2. A server-side log shows connections ending in CLOSE-WAIT that never progress to CLOSED. Using the four-message diagram in this article, explain which side has not yet done its part of the teardown, and what application-level bug (not a network problem) commonly causes it.

Sources