Head-of-line blocking and the QUIC fix
HTTP/2's many streams still ride one in-order TCP connection, so one lost packet stalls every stream until it's resent; QUIC moves streams onto UDP with per-stream loss recovery, so only the affected stream stalls.
Healthy flow-control windows on every stream don't help when the problem is one layer down: because all those streams share a single in-order TCP pipe, one dropped packet holds them all hostage until it's resent.
Scene 10a
Head-of-line blocking and the QUIC fix
- Watch
- Try it
- Predict
- Capture
Recall from the multiplexing scene that gRPC runs many independent streams down one HTTP/2 connection — here, ten concurrent greet("Ada") calls. We say multiplexing made the streams independent, and at the application layer it did: a slow response no longer blocks the others. But every one of those streams still travels inside a SINGLE TCP connection, and TCP hands the receiver one byte stream that it must deliver strictly in order. Watch what happens when one packet — a packet that happens to belong to stream #5 — is dropped. Because TCP refuses to deliver any later byte until the missing one is resent, all ten streams grey out and wait, even though nine of them never lost a thing. When one stalled item at the front holds up everything queued behind it, that's head-of-line blocking — and here it's happening at the transport layer, below the streams HTTP/2 tried to keep independent.
Highlighted lines are the ones running in the diagram right now.
# one HTTP/2 stream per RPC, all on one connectionfor frame in interleave(streams): # round-robinpacket = transport.packetize(frame)packet.seq = next_seq() # one ordered seq spacetransport.send(packet)# window-bounded; only DATA frames are flow-controlledawait peer.WINDOW_UPDATE if window == 0
def on_packet(pkt):buffer[pkt.seq] = pkt# in-order gate: cannot skip a missing seqwhile buffer.has(next_expected):app.deliver(buffer.pop(next_expected))next_expected += 1if gap_at(next_expected):stall() # every later stream waits hererequest_retransmit(next_expected)
def on_packet(pkt): # carries pkt.stream_ids = streams[pkt.stream_id]s.buffer[pkt.offset] = pkt# in-order only WITHIN this streamwhile s.buffer.has(s.next_offset):app.deliver(s.stream_id, s.buffer.pop(s.next_offset))s.next_offset += len(pkt)if gap_at(s.next_offset):s.stall() # other streams keep flowingrequest_retransmit(s.stream_id, s.next_offset)
Where this sits in Build a gRPC-style RPC framework
Scene 10a of 14, in the Resilience act — Client-side balancing, backpressure, head-of-line blocking.. HTTP/2 fixed app-layer head-of-line blocking, but all streams share one in-order TCP pipe, so one lost packet stalls them all. QUIC moves streams below the loss boundary.
Up next. We can now move bytes fast, fairly, and resiliently — but we've never asked WHO is on the other end of the wire, and in a zero-trust fleet 'it's encrypted' is not the same as 'I know who's calling.'
All 14 scenes in Build a gRPC-style RPC framework · Every curriculum