OwlTheEngineer
How It Actually WorksEpisode 08 · Backend

How TCP works: the handshake isn't politeness

Three messages before a byte moves, an ACK that isn't a receipt, and the half a ninety-second video has to cut: how fast to send, how much to hold, and where QUIC takes the whole job away.

Owl · 5 min read · 0 views
– comments
Ep 08 · Backend
60s VISUAL BREAKDOWN
Episode 08
Watch the fast visual breakdown or discuss on social:

The wire under you loses packets, delivers them twice, and out of order — a normal afternoon, not a fault. TCP sits in between and hands your program a clean, ordered stream of bytes. The video shows the reliability half: every byte numbered, an ACK that names what’s expected next, a lost segment that comes back. What it can’t fit is how fast TCP is allowed to send in the first place, what an ACK actually promises, and what happens when the byte stream you get has no message boundaries in it at all.

Everything below is against RFC 9293, Transmission Control Protocol — STD 7, August 2022, which obsoletes RFC 793.

Two windows, and the sender obeys the smaller

Reliability answers what do I do when a segment is lost. It says nothing about how much should have been in flight to begin with, and that’s the question the internet’s survival depends on.

A sender keeps two limits and may only have the smaller amount of data outstanding:

LimitWhoseWhere it lives
rwnd — receive windowthe receiver’sin the header, on every segment
cwnd — congestion windowthe network’sin the sender’s memory, never on the wire

rwnd is how much buffer the far end has — flow control, protecting the peer’s buffer. cwnd is the sender’s private guess at what the path will carry — congestion control, protecting the network. RFC 5681 §3.1 states the rule between them: “the minimum of cwnd and rwnd governs data transmission.” RFC 9293 §3.8.2 makes implementing both mandatory, “to avoid creating congestion collapse conditions.”

A new connection starts with no idea what the path can carry, so cwnd begins small (most stacks now use RFC 6928’s ten segments) and roughly doubles every round trip — this is “slow start,” a bad name for exponential growth. That continues until a threshold, after which the sender switches to adding about one segment per round trip: linear, forever. Sawtooth.

Loss is the only signal, and TCP charges differently depending on how it’s detected:

How loss was foundcwnd after
Retransmission timeout1 segment — back to slow start
Third duplicate ACKroughly halved

Same lost byte, wildly different bill (RFC 5681 §3.1–3.2). A timeout throws away everything the connection learned about the path; three duplicate ACKs just halve it. That’s why fast retransmit — resending on the third duplicate ACK instead of waiting for the timer — matters as much for speed as for congestion cost.

An ACK is a watermark, not a receipt

RFC 9293 §3.4: “an acknowledgment of sequence number X indicates that all octets up to but not including X have been received.” Cumulative. One number, and everything below it is confirmed at once.

Three consequences: a lost ACK usually costs nothing, because the next one covers it. You cannot acknowledge a hole — receive bytes 1–1000 and 2001–3000, and the highest number you can send is 1001, which is what makes the sender see duplicate ACKs. And an ACK is not a read: it means the bytes are in a kernel buffer, not that the application ever called recv().

Which is the whole correction to “TCP guarantees delivery.” It doesn’t. TCP guarantees ordered, deduplicated delivery — or a failure: bytes arrive once each, in order, or the connection breaks and your code is told (RFC 9293 §3.8.3, on excessive retransmission forcing a close). “The write succeeded” means the bytes reached a kernel buffer on your machine — nothing about whether the peer’s application read them or acted on them. That’s why a protocol where something must happen exactly once needs its own acknowledgment, and why idempotency keys exist.

A stream has no messages

RFC 9293 §2.2: TCP provides “a reliable, in-order, byte-stream service.” A byte stream has no message boundaries — TCP never had any to begin with. Two send() calls can arrive as one recv(). One send() can arrive split across three, at an MSS boundary or a retransmission.

So this is broken on every platform, and it will pass every test you run on localhost:

const buf = await socket.read();
const msg = JSON.parse(buf.toString()); // "Unexpected end of JSON input"

You have to frame it yourself, and there are exactly two ways:

// length prefix: read the count, then read exactly that many bytes
async function readMessage(stream) {
  const header = await readExactly(stream, 4);
  const length = header.readUInt32BE(0);
  return readExactly(stream, length);
}

or a delimiter the payload can’t contain (newline-delimited JSON works because JSON escapes newlines in strings). HTTP/1.1 uses both: Content-Length is a length prefix, chunked encoding is a length prefix per chunk, and the blank line after headers is a delimiter. None of that is ceremony — it’s the framing the transport declined to provide.

Half-open connections, and the side that pays for closing

Half-open (RFC 9293 §3.5.1) is the state that ruins afternoons: one peer has closed, crashed, or rebooted, and the other doesn’t know. Nothing informs it. The socket sits in ESTABLISHED against a machine that has forgotten the connection exists, and it finds out only by trying to send, at which point it gets an RST. ESTABLISHED means “I have not been told otherwise,” not evidence of a live peer.

There’s no client or server role built into the protocol either — both sides can send a SYN at once and both walk the same state machine to ESTABLISHED (“simultaneous open,” §3.5). One side just happens to call listen().

Closing has an asymmetry worth knowing: the side that closes first sits in TIME-WAIT for 2×MSL — the spec’s arbitrary 2-minute MSL makes that four minutes, though Linux hardcodes sixty seconds. A server drowning in TIME-WAIT sockets is usually a server that closes its own connections first, which is a design question before it’s a tuning one.

Where QUIC replaces all of this

HTTP/3 doesn’t use TCP at all. QUIC (RFC 9000) is a transport built on UDP, with TLS 1.3 integrated rather than layered, and its own loss detection and congestion control.

JobTCPQUIC
Ordering, retransmissionRFC 9293RFC 9000, per stream
Congestion controlin the kernelin the library
Encryptiona separate TLS layer abovepart of the handshake
Head-of-line blockingone stream — one loss stalls everythingper-stream — a loss stalls only its own stream

The head-of-line line matters most: HTTP/2 multiplexes many requests onto one TCP connection, and TCP’s in-order guarantee then applies to the whole connection — one lost segment holds up every request sharing it. RFC 9000 §13: “when a packet loss occurs, only streams with data in that packet are blocked waiting for a retransmission.”

QUIC rides on UDP not because UDP is fast, but because it’s the only thing that reliably crosses the internet’s middleboxes while leaving transport semantics to the endpoints — and because living in user space means it can update at the pace of a browser release rather than a kernel one. That also means QUIC owes the network everything TCP used to owe on its behalf: an application on raw UDP is expected to control its own sending rate (RFC 8085 §3.1), which is exactly what RFC 9002 does for QUIC.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.

Q1.How should a downstream dependency failure be handled to protect upstream service availability?

Comments –