Head-of-Line Blocking
Because TCP delivers bytes strictly in order, one lost segment holds back every byte behind it even when those bytes have already arrived — a property that HTTP/1.1 dodged with six parallel connections, that HTTP/2’s multiplexing made worse by putting every request on one stream, and that HTTP/3 addresses with QUIC’s independent streams, without eliminating ordering costs inside a stream or in the application.
The problem
The stream promises order
The byte stream’s guarantee — bytes are read in the order they were written — is implemented by the receiver refusing to deliver anything past a hole (Sequence Numbers, ACKs and Reassembly). When B is lost, C waits in the out-of-order queue; the application’s read() returns nothing until B’s retransmission lands, one round trip later at best (Packet Loss: Duplicate ACKs, Fast Retransmit and the RTO) and an RTO later at worst. Then A, B and C are delivered together. That pause is head-of-line blocking: the item at the head of the queue blocks everything behind it.
For a single logical stream this is unavoidable and correct: if C is the continuation of B, the application could not use it anyway. The trouble starts when the transport is carrying several *independent* things that happen to share a connection. TCP has no idea where one request ends and another begins, so a loss in request 1 stalls request 2’s bytes that are already there. The transport enforces an ordering the application never asked for.
sent: [A: request 1][B: request 1 tail][C: request 2][D: request 3] arrived: A ........... (B lost) .......... C ............ D receiver: A deliverable; C, D held (hole at B) app sees: request 1 partial; requests 2 and 3 invisible, though fully received +1 RTT: B retransmitted -> A B C D delivered in one burst
HTTP/1.1: head-of-line blocking at the request level
HTTP/1.1 (HTTP/1.1: Persistent Connections and Their Limits) allows one outstanding request per connection at a time: send a request, wait for the complete response, send the next. Pipelining — sending several requests without waiting — was in the specification but responses still had to come back in order, so one slow response blocked the ones behind it, proxies handled it badly, and browsers disabled it. This is HoL blocking at the *application* layer, before any packet is lost.
The workaround was parallelism: browsers open six connections per host and spread requests across them; sites sharded assets across several hostnames to get more. Six connections means six handshakes, six slow starts, six sets of buffers — and, incidentally, six independent TCP streams, so a loss on one stalls only the requests on that one. The waste bought a kind of loss isolation nobody designed.
HTTP/2: multiplexed streams on one TCP stream
HTTP/2 (HTTP/2: Streams on One Connection) fixes the request-level problem: many streams are interleaved as frames on a single connection, responses can complete in any order, and one slow response no longer blocks the others. One connection also means one handshake, one congestion window that has warmed up, and header compression across requests. The design assumes the transport is a clean pipe.
It is not. All those streams are bytes in *one* TCP sequence space. A single lost segment — carrying, say, a frame of stream 5 — stops delivery of every frame behind it, from every stream, until it is retransmitted. HTTP/2 moved head-of-line blocking from the application layer down to the transport layer, where it now affects *all* requests instead of one. On a clean network that is a fine trade; on a lossy one — mobile, congested Wi-Fi — measurements at the time showed HTTP/2 over a single connection could be slower than HTTP/1.1 over six, because the six connections lost independently. The gain was real; so was the new failure mode.
HTTP/3 and QUIC: streams that are independent at the transport
QUIC (HTTP/3 and QUIC) carries the streams in its own protocol over UDP (UDP: Datagrams and the Contract You Choose). Each stream has its own sequence space and its own reassembly; a lost packet stalls only the streams whose frames it carried — if it carried stream 5, streams 3 and 7 keep delivering. Retransmission is per stream too: QUIC never retransmits a *packet*, it retransmits the *frames* that were in it, in whatever new packet is convenient. This is the property HTTP/3 was built for, and the reason it could not be done on TCP: TCP’s single sequence space is exactly the thing that needed to go.
What QUIC does not eliminate. Within one stream, ordering is still enforced — a lost packet carrying the middle of a large response still stalls the rest of that response, because it must; there is no way to hand the application bytes 3000–3999 of a file before 2000–2999. Packets that carry frames from several streams block all of those streams. And the *application* often imposes ordering the transport knows nothing about: a page that cannot render until its CSS arrives is head-of-line blocked on CSS whatever the transport does, and HTTP/3’s priority scheme exists to manage exactly that. "QUIC eliminates head-of-line blocking" is a slogan; "QUIC removes cross-stream blocking at the transport" is the fact.
| HTTP/1.1 | HTTP/2 | HTTP/3 (QUIC) | |
|---|---|---|---|
| Requests per connection | one at a time | many, multiplexed | many, multiplexed |
| Request-level HoL | yes (one slow response blocks the connection) | no | no |
| Transport-level HoL on loss | per connection (6 connections lose independently) | yes — one loss stalls all streams | per stream only |
| Within-stream ordering | yes | yes | yes (unavoidable) |
| Application-level ordering | yes | yes | yes (priorities help) |
| Handshake cost | 6× TCP (+TLS) | 1× TCP + TLS | 1 RTT combined; 0-RTT resumption |
Where else it appears
Any protocol that multiplexes independent work over one ordered stream inherits the same trade. gRPC runs on HTTP/2 and so shares its transport HoL; a lost packet delays every RPC on that channel. Database protocols that pipeline queries on one connection deliver results in order — a slow query blocks the fast ones queued behind it, which is why drivers keep pools rather than one connection. Redis pipelining returns replies in request order for the same reason. Message brokers reproduce it in their own terms: a partition in Kafka is an ordered log, and one slow message at the head of a partition delays every consumer behind it, exactly as a lost segment does.
The design lesson is general: ordering is a cost, and you should pay it only for things that actually need to be ordered. Put independent work on independent streams — separate connections, separate QUIC streams, separate partitions — and reserve the single ordered stream for the sequence that must be a sequence.
Key points
- TCP’s in-order delivery means a hole stops everything behind it, even bytes that have already arrived.
- Independent work sharing one ordered stream inherits an ordering it did not need; that is the harm.
- HTTP/1.1 had request-level HoL and dodged it with six connections; HTTP/2 removed request-level HoL and concentrated transport-level HoL onto one connection.
- QUIC gives each stream its own sequence space, so a loss stalls only the streams in that packet — the change that required leaving TCP.
- QUIC does not remove ordering within a stream, blocking across streams that share a packet, or ordering the application itself imposes.
- The same pattern shows up in gRPC channels, pipelined database connections and ordered message partitions; pay for ordering only where you need it.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does TCP not just deliver what it has?
Because it does not know where messages begin and end — it sees bytes — and delivering bytes out of order would break every application built on the stream contract. The only way to deliver independent data independently is to tell the transport it is independent, which is what QUIC streams do.
▸Why did HTTP/2 accept the transport-level trade?
Because on most paths loss is rare and the savings — one handshake, one warm congestion window, compressed headers, no six-connection overhead — are paid on every request; the cost appears only on lossy paths. HTTP/3 exists for those paths.
▸Why can’t QUIC remove ordering within a stream?
A stream is by definition a sequence the application wants in order; delivering its middle before its beginning would recreate the framing problem for every application. Independence has to be declared at the stream boundary, not invented below it.
Head-of-line blocking across HTTP versions
How it fails
What the failure looks like from inside real software.
- An HTTP/2 API client on mobile that is slower than the old HTTP/1.1 client on the same network: one lossy connection stalls every in-flight request at once.
- A gRPC channel shared by a service’s critical and bulk calls: a loss during a bulk transfer delays the health check riding on the same connection, and the service is marked unhealthy.
- A database driver with one pipelined connection: a single slow analytical query blocks dozens of fast queries queued behind it while the database is mostly idle.
- HTTP/3 enabled and the large-file endpoint still stalling on loss: the loss was within the one big stream; QUIC cannot hand over bytes past the hole.
- A Kafka consumer group with one poison message at the head of a partition: every message behind it in that partition waits, though the other partitions flow — the broker equivalent of a lost segment.