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.
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:
| Limit | Whose | Where it lives |
|---|---|---|
rwnd — receive window | the receiver’s | in the header, on every segment |
cwnd — congestion window | the network’s | in 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 found | cwnd after |
|---|---|
| Retransmission timeout | 1 segment — back to slow start |
| Third duplicate ACK | roughly 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.
| Job | TCP | QUIC |
|---|---|---|
| Ordering, retransmission | RFC 9293 | RFC 9000, per stream |
| Congestion control | in the kernel | in the library |
| Encryption | a separate TLS layer above | part of the handshake |
| Head-of-line blocking | one stream — one loss stalls everything | per-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.
Test Your Understanding
Quick architectural scenario challenge based on this breakdown.
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 –
Sign in to share your thoughts
Nothing yet. Be the first.