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
| Version | Layer of HOL blocking | Why | Workaround / fix |
| HTTP/1.1 | Application | Ordered responses per connection | ~6 parallel connections |
| HTTP/2 | Transport (TCP) | One in-order byte stream for all streams | None available — TCP is fixed |
| HTTP/3 | None (removed) | QUIC streams recover independently | Solved 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.