Internet Protocols · QUIC and HTTP/3

Head-of-Line Blocking and the Transport Gap QUIC Was Built to Fill

Why one lost TCP segment stalls every multiplexed request on a connection, and which parts of that coupling a UDP-based transport can actually remove.

A browser holds one connection open to a server and issues twenty requests over it, each as an independent stream. One packet is dropped in transit. The other nineteen responses have arrived in full and sit in the receiving host’s memory, yet the application cannot read any of them, because the bytes that were lost belong to an earlier position in a single shared sequence. Every response waits for a retransmission that concerns only one of them. That coupling is the head-of-line blocking problem, and it is a property of the transport contract rather than of any particular application protocol.

The receive queue is ordered by construction

TCP offers an application a reliable, ordered byte stream. The sender numbers bytes, the receiver acknowledges them, and data that arrives ahead of a gap is held rather than delivered. A socket read returns bytes up to the first missing one and no further. This is not conservatism in a particular kernel: a stream reader has no mechanism to express “skip forward”, so handing over bytes from beyond a hole would silently corrupt the abstraction the reader is relying on.

Recovery from the hole follows one of two paths. A sender that sees three duplicate acknowledgements for the same sequence number infers that the next segment was lost and retransmits it without waiting, as specified in RFC 5681. Otherwise the retransmission timer expires first, and the wait is far longer: minimum retransmission timeouts on common stacks are a few hundred milliseconds, against a recovery in roughly one round trip for fast retransmit. Which path is taken depends on whether enough segments were sent after the lost one to provoke three duplicate acknowledgements. A connection carrying one large download almost always has them. A connection carrying many short responses may not, because the loss consumes most of what was in flight.

Multiplexing above a single sequence space

HTTP/2 multiplexes requests by interleaving frames from many streams onto one connection. Each frame begins with a nine-byte header carrying a stream identifier, and frames may be interleaved in any order, with a default maximum frame payload of 16,384 bytes. The framing layer can therefore tell which request a given byte belongs to. The transport cannot. A TCP segment carries a contiguous run of the connection’s sequence space, and that run may contain the tail of one response, the whole of a second, and the beginning of a third.

When such a segment is lost, the gap blocks delivery of everything after it, and everything after it includes data belonging to streams that lost nothing. The HTTP/2 implementation sees an empty socket rather than a partial set of responses. Stream-level flow control does not help, because the obstruction is ordering, not credit. The connection’s throughput collapses to the rate at which the missing range can be retransmitted, and it collapses for all streams at once.

The failure is worst exactly where multiplexing was supposed to pay off. A page load with dozens of small resources has little data in flight per stream, so a single loss can leave the sender without enough subsequent traffic to trigger fast retransmit, and the whole page waits on a timer. Under loss, multiplexing over TCP can perform worse than the parallel connections it replaced, because those connections failed independently.

Three ways out, and what each costs

The first response to the problem is to use more connections. Browsers settled on a convention of about six connections per origin during the HTTP/1.1 era, and that number is a compromise: enough parallelism to hide latency, few enough to avoid overwhelming a server and to keep congestion control meaningful. Each connection costs its own handshake, its own congestion window, and its own kernel state, and a client that opens six connections takes a larger share of a bottleneck than a client that opens one, which is why servers and intermediaries cap concurrency. Most importantly, each connection still blocks internally, so the approach divides the problem rather than removing it.

The second response is redundancy at the application layer: duplicate critical bytes, or add forward error correction so that a lost packet can be reconstructed. Both spend bandwidth continuously to protect against loss that occurs occasionally, and neither changes the ordering coupling. A repaired packet helps only if the repair arrives before the retransmission would have.

The third response is a transport that orders data per stream instead of per connection. SCTP offered exactly this, along with partial reliability, and it did not spread. Middleboxes and NAT implementations handled it inconsistently, it required operating system support that arrived slowly, and it had no mandatory encryption, so its header fields were visible to anything on the path and could be relied upon by equipment that had no business relying on them.

Why the replacement had to be UDP

A new transport has to reach endpoints through the existing network. Firewalls, NATs, and load balancers forward TCP and UDP; IP protocol numbers other than those two are commonly dropped or mishandled, which removes the option of a clean new protocol at the IP layer. Choosing UDP keeps the packet routable and leaves the rest of the design free.

The freedom matters because the transport can then live in user space. A change to TCP congestion behaviour or loss recovery has to be implemented in every operating system kernel and deployed to endpoints that update on their own schedules, and any new TCP option has to survive every middlebox on the path, many of which discard options they do not recognise. A UDP-based transport ships with the application. Encryption is mandatory in QUIC, which means the fields a middlebox can inspect are limited to a small invariant header, and the parts that could otherwise be hardened into place are protected.

The cost is that everything the kernel used to do must be rebuilt. There is no transport retransmission, no receive buffer autotuning, no congestion control, and no path MTU discovery supplied by the platform. QUIC carries its own acknowledgement logic, its own loss detection, its own flow control, and its own congestion controller, and it must discover the path MTU itself because IP fragmentation is unreliable in practice: a datagram that needs fragmentation may be dropped by a filter that never sees the reassembled result. The specification requires Initial packets to be padded to at least 1,200 bytes, which both probes the path and gives the handshake room to work.

UDP also carries operational penalties. Some networks rate-limit or block it, treating it as suitable only for tunnelling or bulk transfer, and a host that has never had to implement congestion control now has to do so correctly or it will be unfair to the traffic it shares a link with. Hardware assistance exists but is thinner than for TCP, so per-packet costs are higher, which pushes implementations toward batching and toward sending larger datagrams.

Anti-amplification, and the constraint it puts on the handshake

Because a UDP server can be told to send to an address that never asked for anything, QUIC limits a server to three times the number of bytes it has received from an unvalidated address. The limit applies until the client proves it can receive at that address, either by completing a handshake or by returning a token the server issued in a Retry packet. A server certificate chain is often larger than three times a client’s first flight, so the handshake is shaped by this rule: the client’s first flight is padded, and a server that cannot fit its response within the budget must ask the client to repeat the attempt with a token.

What survives the change

Head-of-line blocking within a single stream remains, and should remain: a response is a byte stream, and delivering it out of order would be meaningless to most parsers. What disappears is the coupling between streams. A lost packet delays the stream whose data it carried and no other, which is the property described in how QUIC assigns and opens streams.

Loss still costs time. Recovering a lost range takes about one round trip once the loss is detected, and detection itself needs either enough later packets to produce duplicate acknowledgements or an explicit probe after a timeout, as covered in loss detection without retransmitted packets. The improvement is bounded by that, not by the elimination of loss.

There is a second-order risk. A transport that becomes widely deployed acquires equipment that depends on its details, and TCP’s experience shows that such dependence is hard to remove. QUIC reserves version numbers of the form 0x?a?a?a?a for exercising version negotiation, so that clients and servers which quietly assume version 1 fail during interoperability testing rather than freezing the protocol. The same reasoning explains why the version field is explicit and why Version Negotiation exists: the design assumes the format will change.

References

  1. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF
  2. RFC 9113: HTTP/2 — IETF
  3. RFC 5681: TCP Congestion Control — IETF
  4. RFC 9114: HTTP/3 — IETF