🚧 Head-of-Line Blocking

The single problem that drove every HTTP rewrite — the same trap, chased down three layers of the stack.

The General Shape

Head-of-line (HOL) blocking: a queue where the item at the front, when stuck, freezes everything behind it — even items that are ready to go. The blocked item holds the line hostage.

HTTP's entire evolution is the story of finding this queue at one layer, removing it, and discovering it waiting one layer down. Three times.

flowchart LR subgraph Q["A single queue"] H["🐌 slow item (head)"] --> R1["✅ ready"] --> R2["✅ ready"] --> R3["✅ ready"] end H --> X["everything behind waits"]

Layer 1 — Application: HTTP/1.1 Pipelining

Where: the HTTP layer itself. HTTP/1.1 requires responses to come back in request order. Pipeline /slow, /fast, /fast and the two fast responses are trapped behind the slow one.
→ GET /slow      (2s query)
→ GET /fast-a    (5ms)
→ GET /fast-b    (5ms)

← must send /slow first ......... 2s
← then /fast-a
← then /fast-b        total ≈ 2s, not 5ms

This made pipelining useless in practice, so browsers instead opened ~6 parallel connections — buying independence at the cost of handshakes and memory.

Layer 2 — Transport: HTTP/2 over TCP

HTTP/2 fixed layer 1: streams are multiplexed and responses can return in any order. But it runs all streams over one TCP connection, and TCP delivers a single in-order byte stream.

Where: the TCP layer, beneath HTTP. Lose one segment and TCP refuses to hand any later bytes to the application until it's retransmitted — including frames for streams that have nothing to do with the loss. HTTP/2 can't see this; it's happening below it.
flowchart TB P1["packet: stream 1 (LOST ❌)"] P2["packet: stream 3 ✅ arrived"] P3["packet: stream 5 ✅ arrived"] TCP["TCP buffer: must deliver in order"] P2 --> TCP P3 --> TCP TCP --> APP["App gets nothing until stream 1 is retransmitted"]
The cruel irony: on a lossy link, HTTP/2's single connection can underperform HTTP/1.1's six connections — because those six fail independently, while HTTP/2 put all its eggs in one TCP basket.

Layer 3 — Solved: HTTP/3 over QUIC

Where it goes away: QUIC makes streams first-class in the transport. Loss recovery is per-stream, so a lost packet only blocks its own stream. There is no shared in-order byte queue for an unrelated stream to get stuck behind.
flowchart TB LP["packet: stream 1 (LOST ❌)"] OK3["packet: stream 3 ✅"] OK5["packet: stream 5 ✅"] QUIC["QUIC: independent per-stream delivery"] OK3 --> QUIC OK5 --> QUIC QUIC --> D3["stream 3 → app ✅"] QUIC --> D5["stream 5 → app ✅"] LP --> W1["only stream 1 waits ⏳"]

To get there, QUIC had to leave TCP behind entirely and rebuild reliability on UDP — which is exactly why HTTP/3 is a transport rewrite, not just another framing tweak.

The Three Layers, Side by Side

VersionLayer of HOL blockingWhyWorkaround / fix
HTTP/1.1ApplicationOrdered responses per connection~6 parallel connections
HTTP/2Transport (TCP)One in-order byte stream for all streamsNone available — TCP is fixed
HTTP/3None (removed)QUIC streams recover independentlySolved by design
Not gone forever, just moved: QUIC removes network-level HOL blocking, but a single stream is still ordered (that's what "stream" means), and application logic can reintroduce its own HOL blocking. The lesson is that the trap lives wherever there's a shared ordered queue — find the queue, and you'll find the block.

Key Takeaways

Real-world: this one concept explains 90% of "why does HTTP keep changing?" Each version removed a HOL queue and exposed the next one down. It also shows up far outside HTTP — message queues, gRPC streams, and any ordered log have the same failure mode. When latency spikes for all your concurrent work at once, suspect a shared ordered queue.