TCP Internals Connection lifecycle, TIME_WAIT, the Nagle algorithm

 

Chapter 61 · Part 5 of 7
TCP Internals
Connection lifecycle, TIME_WAIT, the Nagle algorithm, and what every socket programmer must know

Why You Need to Understand TCP Internals

Most socket programming bugs aren’t coding errors — they’re misunderstandings of how TCP works. Why does my server fail to bind after a crash? Why does my application feel sluggish sending small messages? Why does my client hang in CLOSE_WAIT?

This tutorial walks through the TCP connection lifecycle, explains the states you’ll see in netstat, and explains the Nagle algorithm and when to disable it.

TCP Connection Establishment: The 3-Way Handshake

Every TCP connection begins with a 3-way handshake. This synchronises sequence numbers between the two endpoints.

CLIENT
SERVER
CLOSED
LISTEN
SYN_SENT
── SYN (seq=x) ──→
SYN_RECEIVED
ESTABLISHED
←── SYN+ACK (seq=y, ack=x+1) ──
── ACK (ack=y+1) ──→
ESTABLISHED
Both sides now ESTABLISHED — data can flow

From a socket API perspective, the handshake happens transparently:

Server: socket() → bind() → listen() → accept() [blocks until handshake completes]
Client: socket() → connect() [triggers SYN, blocks until ESTABLISHED]

TCP State Machine

TCP connections move through a series of states. Understanding these is essential for reading netstat output and debugging.

State
Meaning
Who enters it
LISTEN
Waiting for incoming connections
Server after listen()
SYN_SENT
SYN sent, waiting for SYN+ACK
Client during connect()
SYN_RECEIVED
SYN received, SYN+ACK sent
Server
ESTABLISHED
Connection open, data can flow
Both sides
FIN_WAIT_1
FIN sent, waiting for ACK of FIN
Active closer
FIN_WAIT_2
ACK received, waiting for peer’s FIN
Active closer
CLOSE_WAIT
Peer’s FIN received, waiting for local close()
Passive closer
TIME_WAIT
Both FINs exchanged — waiting 2×MSL before fully closing
Active closer
CLOSED
Connection fully terminated
Both sides

TIME_WAIT: The Most Misunderstood State

TIME_WAIT is entered by the side that initiates the close (sends the first FIN). It persists for 2 × MSL (Maximum Segment Lifetime) — typically 60 seconds on Linux (2 × 30s).

During TIME_WAIT, the port cannot be reused for a new connection to the same destination. This causes a common problem: you restart a server and get “Address already in use”.

Why TIME_WAIT must exist (two reasons):
Reason 1 — Reliable FIN acknowledgment:
The active closer sends the final ACK for the peer’s FIN. If this ACK is lost, the peer will retransmit its FIN. TIME_WAIT keeps the socket alive long enough to receive and re-ACK that retransmitted FIN. Without TIME_WAIT, the retransmitted FIN would be RST’d, breaking the protocol.
Reason 2 — Old duplicate packets:
Delayed or duplicate packets from the old connection must not be accepted by a new connection on the same {src IP, src port, dst IP, dst port} 4-tuple. TIME_WAIT ensures the old connection’s packets have expired (lived their MSL) before the 4-tuple can be reused.

Solving “Address Already In Use” with SO_REUSEADDR

The fix is to set SO_REUSEADDR before calling bind(). This allows binding to a port that has sockets in TIME_WAIT state from a previous incarnation of the server.

#include <sys/socket.h>

int setup_server_socket(int port)
{
    int                sockfd;
    struct sockaddr_in addr;
    int                opt = 1;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) { perror("socket"); return -1; }

    /*
     * SO_REUSEADDR: allow bind() even if the port has
     * sockets in TIME_WAIT from a previous server run.
     * ALWAYS set this on server sockets.
     */
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
        perror("setsockopt SO_REUSEADDR");
        close(sockfd);
        return -1;
    }

    memset(&addr, 0, sizeof(addr));
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(port);

    if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) == -1) {
        perror("bind");
        close(sockfd);
        return -1;
    }

    return sockfd;
}
Rule of thumb: Always set SO_REUSEADDR on TCP server sockets before bind(). There is virtually no downside, and it prevents the annoying “Address already in use” error during development and server restarts.

The CLOSE_WAIT Problem

If you see many sockets in CLOSE_WAIT in netstat, your application has a bug — it’s not calling close() on connections after the peer has closed their end.

CLOSE_WAIT sequence:
1. Peer calls close() → sends FIN
2. Your side receives FIN → read() returns 0 (EOF)
3. Your side enters CLOSE_WAIT — must now call close()
BUG: your code ignores the EOF and never calls close()
→ Socket stays in CLOSE_WAIT forever, leaking resources
/* BUGGY code — causes CLOSE_WAIT accumulation */
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
    process(buf, n);
}
/* n == 0 means EOF (peer closed) — but we forget to close! */

/* FIXED */
while ((n = read(connfd, buf, sizeof(buf))) > 0) {
    process(buf, n);
}
if (n == 0) {
    printf("Peer closed — closing our side\n");
    close(connfd);   /* This completes the 4-way close, exiting CLOSE_WAIT */
} else if (n == -1) {
    perror("read");
    close(connfd);
}

The Nagle Algorithm

TCP was designed in an era of slow, congested networks. John Nagle invented an algorithm (RFC 896) to prevent a flood of tiny packets — the so-called “small packet problem” or “tinygram problem”.

Nagle’s rule: A TCP endpoint may send a small segment only if there are no unacknowledged data segments outstanding. If there is outstanding unacknowledged data, the new data is buffered and sent as one larger segment when the ACK arrives.

Nagle enabled vs disabled — sending 3 small writes of 10 bytes each:
Nagle ON (default)
write(10 bytes) → sent immediately (no pending ACK)
write(10 bytes) → buffered, waiting for ACK of first
write(10 bytes) → buffered
← ACK arrives
→ 20 bytes sent in ONE packet
Result: 2 packets instead of 3
Nagle OFF (TCP_NODELAY)
write(10 bytes) → sent immediately
write(10 bytes) → sent immediately
write(10 bytes) → sent immediately
Result: 3 packets
But: zero additional latency

When to disable Nagle (set TCP_NODELAY): Real-time interactive applications — SSH key press echoing, multiplayer games, telnet, financial trading systems. Any application where latency matters more than bandwidth efficiency.

#include <netinet/tcp.h>

/* Disable Nagle algorithm on a socket */
int disable_nagle(int sockfd)
{
    int flag = 1;   /* 1 = disable Nagle (enable TCP_NODELAY) */

    if (setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY,
                   &flag, sizeof(flag)) == -1) {
        perror("setsockopt TCP_NODELAY");
        return -1;
    }
    printf("Nagle algorithm disabled — small writes sent immediately\n");
    return 0;
}

/* Re-enable Nagle (usually not needed — it's on by default) */
int enable_nagle(int sockfd)
{
    int flag = 0;
    return setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY,
                      &flag, sizeof(flag));
}

TCP Connection Timeout and Retransmission

When connect() is called and no SYN+ACK arrives, TCP retransmits the SYN with exponential backoff. On Linux, the default timeout is around 127 seconds (after roughly 6 retransmission attempts). This can make your application hang for over 2 minutes on a dead host.

#include <sys/socket.h>
#include <fcntl.h>
#include <sys/select.h>
#include <errno.h>

/*
 * Non-blocking connect with a custom timeout.
 * Returns 0 on success, -1 on error or timeout.
 */
int connect_with_timeout(int sockfd, struct sockaddr *addr,
                         socklen_t addrlen, int timeout_sec)
{
    int    flags, result;
    fd_set wfds;
    struct timeval tv;

    /* Set socket to non-blocking */
    flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    result = connect(sockfd, addr, addrlen);

    if (result == 0) {
        /* Connected immediately (e.g., loopback) */
        fcntl(sockfd, F_SETFL, flags);  /* restore blocking */
        return 0;
    }

    if (errno != EINPROGRESS) {
        perror("connect"); return -1;
    }

    /* Wait for the socket to become writable (connection complete) */
    FD_ZERO(&wfds);
    FD_SET(sockfd, &wfds);
    tv.tv_sec  = timeout_sec;
    tv.tv_usec = 0;

    result = select(sockfd + 1, NULL, &wfds, NULL, &tv);

    if (result == 0) {
        fprintf(stderr, "connect timed out after %d seconds\n", timeout_sec);
        return -1;
    }
    if (result == -1) {
        perror("select"); return -1;
    }

    /* Check if connection actually succeeded */
    int soerr;
    socklen_t solen = sizeof(soerr);
    getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &soerr, &solen);
    if (soerr != 0) {
        fprintf(stderr, "connect failed: %s\n", strerror(soerr));
        return -1;
    }

    fcntl(sockfd, F_SETFL, flags);  /* restore blocking mode */
    printf("Connected successfully\n");
    return 0;
}

Interview Questions & Answers

Q1. Why does TIME_WAIT exist and how long does it last?
TIME_WAIT exists for two reasons: (1) to ensure the final ACK of the connection teardown can be retransmitted if lost, and (2) to ensure no delayed duplicate packets from the old connection can corrupt a new connection on the same 4-tuple. It lasts 2×MSL, typically 60 seconds on Linux.
Q2. Why do I get “Address already in use” when restarting a TCP server?
The previous server’s listening socket (or its accepted connections) may be in TIME_WAIT. bind() fails because the port is still considered in use. Fix: set SO_REUSEADDR before bind(). This allows binding to a port that has TIME_WAIT sockets from a prior incarnation.
Q3. What is CLOSE_WAIT and what causes it to accumulate?
CLOSE_WAIT is entered when your side receives a FIN (reads EOF) but hasn’t called close() yet. A large number of CLOSE_WAIT sockets indicates a bug: the application detected EOF but never closed the socket. This leaks file descriptors and ultimately exhausts them.
Q4. What is the Nagle algorithm and what problem does it solve?
Nagle’s algorithm prevents many small TCP segments from being sent when there is outstanding unacknowledged data. Instead, small writes are buffered until the ACK for the previous segment arrives. This reduces packet count and header overhead on slow or congested networks.
Q5. When should you disable the Nagle algorithm?
Disable it (TCP_NODELAY) for interactive real-time applications where latency is more important than bandwidth: SSH, telnet, financial trading systems, gaming, VoIP signalling. Keep it enabled for bulk data transfers like file servers where throughput matters more.
Q6. What happens during a TCP 4-way close?
Each side independently closes its write half. Active closer sends FIN → passive closer ACKs → passive closer sends its own FIN → active closer ACKs and enters TIME_WAIT. This is 4 messages (FIN, ACK, FIN, ACK) because each FIN must be independently acknowledged.
Q7. What is MSL and why does TIME_WAIT last 2×MSL?
MSL (Maximum Segment Lifetime) is the maximum time a TCP segment can remain in the network before being discarded. On Linux it’s 30 seconds. TIME_WAIT lasts 2×MSL (60 seconds) to cover the worst case: the final ACK takes MSL to reach the peer, and the peer’s retransmitted FIN takes another MSL to come back. This guarantees all old segments from the connection have expired.

Leave a Reply

Your email address will not be published. Required fields are marked *