TCP in Depth

Almost every byte you have ever read on the web arrived by TCP. When you loaded this page, two computers performed a small, precise ceremony — a handshake to agree where to start counting, a stream of numbered bytes flowing one way and acknowledgements flowing back, timers quietly watching for silence, and finally a graceful goodbye. TCP is the workhorse of the internet, and having built up reliable data transfer from first principles, we can now see how the real protocol assembles those ideas into a connection that lasts.

This page dissects TCP as it actually runs: the fields in its header, the three-way handshake and why it takes three messages rather than two, how a connection is torn down (and the curious TIME_WAIT pause at the end), the way ACKs count bytes, and how TCP measures the round trip so it knows how long to wait before assuming a packet is lost.

The segment: sequence and ACK numbers count bytes

A TCP segment is a header (20 bytes without options) followed by application data. The two headline fields are the sequence number and the acknowledgement number, and the single most important thing to know about them is that they count bytes, not packets.

Alongside them sit the control flags — single bits that change a segment's meaning:

FlagMeaning
SYNSynchronise — open a connection, carrying the initial sequence number
ACKThe acknowledgement number field is valid (set on nearly every segment after the first)
FINFinish — this side has no more data to send; begin an orderly close
RSTReset — abort the connection abruptly (e.g. segment for a connection that doesn't exist)

The header also carries the source and destination ports (the demux 4-tuple), a receive window for flow control, and a checksum computed just like UDP's. The whole reliable, ordered stream is choreographed by those byte-counting numbers.

The three-way handshake — and why three

Before any data flows, the two sides must agree on their starting sequence numbers. Each side chooses a random initial sequence number (ISN) and must tell the other what it is and confirm it heard the other's. That mutual agreement is what the three messages accomplish:

Why not two? Because each direction of the connection needs its sequence number acknowledged, and a two-message exchange can only confirm one direction. The third message is the client confirming it received the server's ISN. Two messages would leave the server unsure whether the client ever heard its chosen starting point — and, worse, would make the server vulnerable to old, duplicate SYNs: a stray SYN from a long-dead connection could trick a two-way server into opening a phantom connection. The three-way exchange lets each side confirm the other is genuinely present and listening now, defeating those delayed duplicates.

Teardown, and the point of TIME_WAIT

Closing is a four-way affair because each direction closes independently — TCP connections are full-duplex, so each side sends its own FIN and gets its own ACK. One side can finish sending (FIN) while still receiving, a "half-close." Once both FINs are acknowledged, the connection is done — almost. The side that closed first (usually the client) enters a state called TIME_WAIT and lingers there for roughly 2 \cdot \text{MSL} (twice the Maximum Segment Lifetime, often a minute or two) before releasing the connection completely.

Operators see thousands of sockets stuck in TIME_WAIT and panic — "my connections aren't closing, something's leaking!" — and reach for hacks to kill it. That is a mistake. TIME_WAIT is doing exactly its job, and it exists for two solid reasons:

So TIME_WAIT is a correctness feature, not a leak. It is the side that initiated the close that pays the cost — which is one reason high-throughput servers prefer to let clients close first, so the TIME_WAIT burden lands on the many clients rather than the one server.

Measuring the round trip: adaptive timeout

TCP's retransmission depends on a timeout, but what value? Too short and it retransmits needlessly; too long and it dawdles after a real loss. And the right value changes constantly — the RTT to a server varies with congestion and route. So TCP measures it continuously and adapts.

Each time a segment is acknowledged, TCP takes a SampleRTT (how long that segment took to be ACKed) and folds it into a smoothed running average — an exponentially weighted moving average (EWMA):

\text{EstimatedRTT} = (1-\alpha)\cdot\text{EstimatedRTT} + \alpha\cdot\text{SampleRTT}, \quad \alpha = 0.125

It also tracks how much the samples jitter — the deviation — because a variable link needs a bigger safety margin than a steady one:

\text{DevRTT} = (1-\beta)\cdot\text{DevRTT} + \beta\cdot|\text{SampleRTT}-\text{EstimatedRTT}|, \quad \beta = 0.25

The retransmission timeout is the estimate plus a generous margin proportional to that jitter:

\text{RTO} = \text{EstimatedRTT} + 4\cdot\text{DevRTT}

The factor of four means a bursty, jittery path gets a comfortably longer timeout, avoiding spurious retransmissions, while a rock-steady path gets a tight one that reacts to real loss quickly. The demo runs the EWMA over a few noisy samples:

// TCP's adaptive timeout: EWMA of SampleRTT, plus a jitter-scaled margin. const alpha = 0.125, beta = 0.25; let est = 100; // start EstimatedRTT at 100 ms let dev = 20; // start DevRTT at 20 ms const samples = [100, 108, 95, 140, 102, 98, 210, 105, 99, 101]; // ms, note the spike console.log("sample Est Dev RTO"); for (const s of samples) { est = (1 - alpha) * est + alpha * s; dev = (1 - beta) * dev + beta * Math.abs(s - est); const rto = est + 4 * dev; console.log( `${String(s).padStart(4)} ${est.toFixed(1).padStart(6)} ${dev.toFixed(1).padStart(6)} ${rto.toFixed(1).padStart(6)} ms`, ); } console.log("\nThe 210 ms spike nudges Est up a little but bumps Dev a lot,"); console.log("so the RTO widens to avoid calling a merely-slow segment 'lost'.");

There's a subtle trap in measuring RTT. Suppose you send a segment, time out, retransmit, and then an ACK arrives. Was that ACK for the original or the retransmission? You can't tell — so you can't know the true SampleRTT. Karn's algorithm says: simply don't take an RTT sample from any retransmitted segment. To stay safe after a loss, TCP also doubles the RTO on each timeout (exponential backoff) until a clean, non-retransmitted ACK gives it a fresh sample to reset from. Ambiguous measurements are worse than no measurement.