How a TCP connection closes gracefully — the four-step FIN sequence, TIME_WAIT in depth, half-close, and common bugs explained with code.
Closing a TCP Connection
Opening a TCP connection takes three segments (three-way handshake). Closing it normally takes four segments. This is because each side independently closes its half of the connection, and each closure requires a FIN and an ACK.
There are two roles in a TCP close:
The side that calls close() first. This side goes through FIN_WAIT1 → FIN_WAIT2 → TIME_WAIT → CLOSED.
The side that receives the FIN first. This side goes through CLOSE_WAIT → LAST_ACK → CLOSED.
In typical client-server usage, the client is usually the active closer (it finishes its request and closes first). But this is not required — either side can close first.
The Four-Step Close Sequence
The application on the active closer calls close(). The TCP sends a FIN segment to the peer. Like the SYN, the FIN flag also consumes one sequence number. The active closer moves to FIN_WAIT1, waiting for the peer’s ACK.
After sending the FIN, the active closer can no longer send data. However, it can still receive data — the connection is half-closed from the sender’s perspective (more on this below).
When the passive closer’s TCP receives the FIN, it immediately sends an ACK (the kernel does this automatically, without waiting for the application). The passive closer’s socket moves to CLOSE_WAIT. The active closer, upon receiving this ACK, moves from FIN_WAIT1 to FIN_WAIT2.
On the passive closer’s application side: the next read() call on the socket will return 0 (end-of-file). This is how the application finds out that the peer has closed. The application must now decide to call close() as well.
Eventually the passive closer’s application calls close() (after processing remaining data, if any). The TCP sends a FIN to the active closer and moves to LAST_ACK, waiting for the final ACK.
The active closer receives the FIN from the passive closer. It sends a final ACK and moves to TIME_WAIT. The passive closer receives this ACK and immediately moves to CLOSED (no TIME_WAIT needed on the passive closer’s side). After 2×MSL (120 seconds on Linux), the active closer also moves to CLOSED.
Complete Termination Timeline
(e.g. Client)
(e.g. Server)
Half-Close: shutdown() vs close()
After the active closer sends its FIN (step 1), the connection is half-closed. The active closer can no longer send data, but the passive closer can still send data and the active closer can still receive it. This is a legitimate and sometimes useful TCP feature.
You can explicitly create a half-close using shutdown() instead of close():
#include <sys/socket.h>
/* shutdown() flags */
/* SHUT_WR - close only the write direction (send FIN) */
/* SHUT_RD - close only the read direction */
/* SHUT_RDWR - close both (same effect as close()) */
int fd = /* connected socket */;
/*
* Half-close: stop sending but keep receiving.
* This sends a FIN to the peer, signaling end of our data.
* We can still read() data coming from the peer.
*/
shutdown(fd, SHUT_WR);
/* Example use case: client sends a command and half-closes.
Server knows the full command has arrived, sends the response.
Client reads the response, then calls close(). */
char buf[4096];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
/* Process server's response */
fwrite(buf, 1, n, stdout);
}
/* Now fully close */
close(fd);
Difference between shutdown() and close():
TIME_WAIT: Deep Dive
MSL stands for Maximum Segment Lifetime — the maximum time any TCP segment is allowed to exist in the network before being discarded. RFC 793 sets MSL at 2 minutes. Linux uses 60 seconds. So TIME_WAIT duration = 2 × 60 = 120 seconds.
/* MSL on Linux: TCP_TIMEWAIT_LEN defined in kernel source
as 60 seconds (HZ * 60).
You can see TIME_WAIT duration indirectly: */
$ cat /proc/sys/net/ipv4/tcp_fin_timeout
60
/* tcp_fin_timeout controls FIN_WAIT2 duration.
TIME_WAIT duration (2×MSL = 120s) is hardcoded in kernel. */
In step 4, the active closer sends the final ACK to the passive closer. What if this ACK is lost in the network? The passive closer would retransmit its FIN. If the active closer had already moved to CLOSED, there is nobody left to answer that retransmitted FIN. The TCP would send an RST instead, which is incorrect. By staying in TIME_WAIT for 2×MSL, the active closer can still receive and respond to a retransmitted FIN with a fresh ACK.
Suppose a connection between A:1234 and B:8080 is closed, and immediately a new connection is opened between the same A:1234 and B:8080. Old TCP segments from the previous connection could still be floating in the network (held up by a slow router). If they arrive at the new connection, they could be interpreted as data for the new connection and corrupt it. Waiting 2×MSL ensures all segments from the old connection have expired before the same IP:port pair can be reused.
On busy servers, TIME_WAIT sockets accumulate. Here is how to deal with them:
/* Option 1: SO_REUSEADDR — allows a new server to bind to
the same port even if there are TIME_WAIT sockets on it.
This is standard practice for server programs. */
int opt = 1;
setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sfd, ...); /* Will succeed even if old TIME_WAIT exists on this port */
/* Option 2: tcp_tw_reuse — allows reusing TIME_WAIT sockets
for NEW OUTGOING connections when timestamps are in use.
Safe to enable. Only affects client-side (outgoing) connections. */
$ sysctl -w net.ipv4.tcp_tw_reuse=1
/* Option 3: SO_LINGER with l_onoff=1, l_linger=0 — causes an
RST (reset) instead of a normal FIN sequence.
Socket closes immediately without entering TIME_WAIT.
Use with caution — abrupt, may lose buffered data. */
struct linger lg;
lg.l_onoff = 1;
lg.l_linger = 0;
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
close(fd); /* Sends RST, not FIN. No TIME_WAIT. */
/* Option 4: Keep-alive and connection pooling
The best solution — reuse TCP connections instead of
creating a new one for every request (HTTP/1.1 keep-alive,
database connection pools, etc.) This reduces TIME_WAIT
sockets dramatically. */
RST vs FIN: Two Ways to Close
There are two ways a TCP connection can end:
- Triggered by
close()orshutdown() - Graceful — both sides agree to stop
- All buffered data is sent before FIN
- Results in TIME_WAIT on active closer
- Called “orderly release” in TCP terminology
- Triggered by
SO_LINGERwith timeout=0, or kernel discard - Abrupt — immediately destroys the connection
- Buffered data may be discarded
- No TIME_WAIT — instant close
- Peer gets “Connection reset by peer” error
/* Normal close: graceful, sends FIN */
close(fd);
/* Abortive close: sends RST, no TIME_WAIT */
struct linger lg = { .l_onoff = 1, .l_linger = 0 };
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lg, sizeof(lg));
close(fd);
/* On the peer: next read() will fail with errno = ECONNRESET */
n = read(peer_fd, buf, len);
if (n == -1 && errno == ECONNRESET) {
printf("Connection was reset by peer\n");
}
Common Bug: CLOSE_WAIT Accumulation
One of the most common TCP bugs in server code is the accumulation of CLOSE_WAIT sockets. This happens when a server receives a FIN (peer closed) but the server application code does not call close() in response. Here is a detailed look:
Buggy server code that causes CLOSE_WAIT leak:
void handle_client(int cfd) {
char buf[256];
while (1) {
ssize_t n = read(cfd, buf, sizeof(buf));
if (n < 0) {
perror("read");
break; /* Error — we break but DO NOT close(cfd) */
}
if (n == 0) {
/* Client closed connection — read() returns 0 */
/* BUG: We break out of the loop but forget close(cfd) */
/* Socket stays in CLOSE_WAIT forever until process exits */
printf("Client disconnected\n");
break;
}
/* Process data... */
write(cfd, buf, n); /* Echo back */
}
/* cfd is LEAKED here! Never closed. CLOSE_WAIT accumulates. */
}
Fixed version:
void handle_client(int cfd) {
char buf[256];
while (1) {
ssize_t n = read(cfd, buf, sizeof(buf));
if (n < 0) {
perror("read");
close(cfd); /* FIX: Always close on error */
return;
}
if (n == 0) {
/* Client closed its write side.
We close our side too → sends FIN to client.
Socket moves: CLOSE_WAIT → LAST_ACK → CLOSED */
printf("Client disconnected cleanly\n");
close(cfd); /* FIX: Critical! Move out of CLOSE_WAIT */
return;
}
write(cfd, buf, n);
}
}
How to detect CLOSE_WAIT leak in production:
/* Check for growing CLOSE_WAIT count */
$ watch -n 5 'ss -tan | grep -c CLOSE-WAIT'
/* If the number keeps growing and never drops,
you have a CLOSE_WAIT leak bug in your server. */
/* Find which process owns the stuck sockets */
$ ss -tanp | grep CLOSE-WAIT
CLOSE-WAIT 1 0 192.168.1.10:8080 192.168.1.20:54321 users:(("myserver",pid=1234,fd=7))
^^^ this process has the bug
/* Check file descriptor count — a leaking server will show high fd count */
$ ls /proc/1234/fd | wc -l
Observing Connection Termination with tcpdump
/* Capture the four-step close */
$ sudo tcpdump -i lo port 8080 -n
/* Output during normal close: */
/* Step 1: Client sends FIN */
12:05:10.001 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [F.], seq 100, ack 200, length 0
/* Step 2: Server sends ACK */
12:05:10.002 IP 127.0.0.1.8080 > 127.0.0.1.54321: Flags [.], ack 101, length 0
/* Step 3: Server sends its FIN */
12:05:10.010 IP 127.0.0.1.8080 > 127.0.0.1.54321: Flags [F.], seq 200, ack 101, length 0
/* Step 4: Client sends final ACK */
12:05:10.011 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [.], ack 201, length 0
/* Flags:
[F.] = FIN + ACK
[.] = ACK only
[R] = RST (abortive close — only one segment, not four)
*/
/* Abortive close (RST) looks like: */
12:05:10.001 IP 127.0.0.1.54321 > 127.0.0.1.8080: Flags [R.], seq 100, ack 200, length 0
/* Just one RST segment — done. No TIME_WAIT. */
Interview Questions
Answer: Because TCP is a full-duplex protocol — each direction of data flow is independent. When one side closes (sends FIN), it only means that side is done sending. The other side can still have data to send. The four-step sequence allows each side to independently signal the end of its data. You need: FIN from active closer, ACK from passive closer, FIN from passive closer (after it finishes), ACK from active closer. If the passive closer had no more data, steps 2 and 3 could be combined into one FIN-ACK, but this is not always possible.
Answer: Both send a FIN to the peer. The key differences: (1) close() decrements the socket’s reference count and only sends FIN when the count reaches zero (relevant when the socket has been dup’d across threads/processes). shutdown(SHUT_WR) sends FIN immediately regardless of reference count. (2) close() releases the file descriptor so you cannot use it anymore. shutdown(SHUT_WR) keeps the fd open — you can still call read() on it to receive data from the peer (half-close). (3) shutdown() lets you close only one direction (SHUT_RD, SHUT_WR) or both (SHUT_RDWR).
Answer: A half-close is when one side closes only its write direction (sends FIN) but keeps its read direction open. After a half-close, the sender cannot send more data but can still receive data from the peer. It is useful when a client wants to signal it has finished sending its request but still needs to read the server’s response — for example, in protocols where the client sends a command, signals end-of-input with a half-close, and then reads the server’s full response. Use shutdown(fd, SHUT_WR) to perform a half-close.
Answer: If the final ACK (active closer acknowledging the passive closer’s FIN) is lost, the passive closer in LAST_ACK state will retransmit its FIN after a timeout. The active closer, which is in TIME_WAIT state, receives this retransmitted FIN and sends a new ACK, restarting its 2×MSL timer. This is exactly why the active closer must stay in TIME_WAIT rather than immediately moving to CLOSED — it needs to be able to retransmit the final ACK if necessary.
Answer: SO_REUSEADDR allows binding to a port that has TIME_WAIT sockets on it. In practice it is safe and is standard practice for all server programs (to allow quick restart after a crash). The TIME_WAIT protection against stale segments is still provided at the TCP level — SO_REUSEADDR just removes the restriction on bind(), not on the connection’s behavior. The potential (theoretical) risk is that a stale segment from a previous incarnation of the same connection could arrive and be misinterpreted, but this is extremely unlikely given modern random ISNs and TCP timestamps.
Answer: “Connection reset by peer” (errno = ECONNRESET) means the remote side sent an RST segment instead of a normal FIN. This is an abortive close. Common causes: (1) the remote application crashed or was killed and the OS sent RST on cleanup; (2) the remote application called close() with SO_LINGER set to timeout=0; (3) data arrived for a socket that had already been closed; (4) a firewall or NAT device timed out the connection and sent RST; (5) a TCP keepalive probe was not answered and the connection was aborted.
Series Summary
All 11 TCP states, what each means, and how to observe them with ss/netstat.
How TCP moves between states: client path, server path, simultaneous close.
Three-way handshake, ISNs, piggybacking, SYN flood and SYN cookies.
Four-step close, TIME_WAIT explained, half-close, RST vs FIN, CLOSE_WAIT bug.
You’ve Completed Chapter 61 TCP Internals!
Continue the TLPI series or explore Bluetooth/BLE content on EmbeddedPathashala.
