Networking Fundamentals

Ch.35: How HTTP/3 Uses QUIC Streams to Kill Head-of-Line Blocking

By Ayush Arora••9 min read

Inspired by: YouTube

Ch.33 showed why HTTP/2 still stalls: TCP has no idea which segment belongs to which stream, so one lost segment freezes every stream on the connection. Ch.34 introduced the replacement transport, QUIC, and why it hides inside UDP, ships in user space, and folds the handshake down to one round trip. This chapter is the payoff: exactly how HTTP/3 rides on top of QUIC's streams, and a full worked example of a packet going missing to show why the rest of the connection does not care.


HTTP/3 is HTTP semantics over QUIC

HTTP/3 is not a new application protocol. It is the same HTTP you already know, methods, paths, headers, a body, running over a different transport. Everything about what an HTTP request means is unchanged. What changes is how it gets carried.

HTTP/1.1, HTTP/2  ->  HTTP semantics over TCP
HTTP/3            ->  HTTP semantics over QUIC

That single swap (TCP for QUIC) is also why HTTP/3 looks slightly different at the framing level, even though it behaves like HTTP/2 to anyone writing application code.


The stream ID moves down a layer

HTTP/2 invented the stream ID to multiplex many requests over one connection. Every HTTP/2 frame carries a stream ID in its own header, and that ID is an HTTP/2 concept: TCP underneath never sees it, TCP just sees bytes.

HTTP/3 does not have a stream ID in its own frames. That is not a missing feature, it is a deliberate move: the concept of a stream got pushed down into the transport layer, because QUIC already has streams natively. A QUIC connection can carry many independent QUIC streams, each with its own stream ID, and each behaving like an isolated virtual connection.

HTTP/2:  stream ID lives in the HTTP/2 frame header   (application layer)
HTTP/3:  stream ID lives in the QUIC frame header      (transport layer)

HTTP/3 still has its own frames though: a HEADERS frame and a DATA frame, direct analogs of HTTP/2's, just without a stream ID field, because QUIC supplies that instead. Compression works the same way HPACK did for HTTP/2, except HTTP/3 uses QPACK, an HPACK-like scheme adapted so that header compression state does not itself depend on strict in-order delivery. If you followed Ch.32 on HPACK, QPACK keeps the static and dynamic table idea; the differences exist purely to accommodate QUIC's out-of-order delivery.

This one relocation is the entire mechanism behind fixing transport-level head-of-line blocking. Everything else in this chapter is working out its consequences.


Two kinds of frames: don't mix them up

There are two separate concepts here that share the word "frame," and it is easy to conflate them:

The data unit hierarchy from the wire up:

UDP datagram
  QUIC packet          (~ TCP's segment: numbered, this is QUIC's unit of loss/retransmission)
    QUIC frame(s)       (stream / crypto / ack / ...)
      QUIC stream frame carries -> HTTP/3 frame (HEADERS / DATA)

A QUIC packet is roughly the QUIC analog of a TCP segment: it is what gets a packet number and what gets retransmitted if lost. But unlike a TCP segment, which is just a slice of one continuous byte stream, a single QUIC packet can carry multiple QUIC frames from multiple different streams bundled together. That bundling is what makes the worked example below interesting.

Every QUIC stream frame carries a fin flag alongside its stream ID. fin=0 means more data for this stream is still coming; fin=1 means this is the last frame for this stream, so QUIC can treat that stream's request or response as complete once that frame arrives. It is the QUIC-level equivalent of the END_STREAM flag HTTP/2 frames carried.

One thing worth being precise about: QUIC is a general-purpose transport-layer protocol, not something built specifically for HTTP/3. It is easy to assume QUIC and HTTP/3 are the same thing, but other protocols can run over QUIC too, DNS-over-QUIC is a real example. HTTP/3 is one application built on top of QUIC, the same way HTTP/1.1 and HTTP/2 are applications built on top of TCP.


Worked example: three requests, three packets, one loss

Take three independent HTTP/3 requests going out on the same QUIC connection:

StreamRequestFrames needed
stream 2POST /signupHEADERS + DATA
stream 4POST /ordersHEADERS + DATA
stream 6GET /index.htmlHEADERS only

QUIC is free to pack these frames into packets however it likes, and it does not have to keep one request's frames together or send them in any particular order relative to other streams. Say it lays them out like this:

All three packets go into UDP datagrams and travel independently over IP, in whatever order the network delivers them, same as any other IP packets. Now suppose packet 2 is lost, but packets 1 and 3 both arrive.

Walk through what the receiver does:

The result: two of three requests complete without any delay at all, and only the one request whose data was actually inside the lost packet waits. Compare that to what TCP would do with the equivalent HTTP/2 setup: TCP has no stream concept, so a lost segment blocks every byte behind it in sequence order, regardless of which request those bytes belonged to. QUIC's per-frame stream ID is what breaks that dependency.

QUIC packet 2 in transit

Three requests, three QUIC packets. stream 2 (POST /signup) and its full body land in packet 1. Packet 1 also carries the stream 4 (POST /orders) HEADERS frame with FIN: 0, its body arrives separately in packet 2. Packet 3 carries stream 6 (GET /index.html), which depends on neither. Each QUIC frame is a self-contained wrapper around an HTTP/3 frame, tagged with its own stream ID, offset, and FIN flag. Toggle packet 2 to see who is affected.

QUIC Packet
  • Packet Number: 1
  • Payload: QUIC Frames
  • ...other packet headers
(carries 3 frames)
QUIC Frame
  • Type: STREAM Frame (more types)
  • Stream ID: 2
  • Offset: 0
  • Length: 500 (bytes of QUIC frm data)
  • Data: HTTP/3 Frame Bytes
  • FIN: 0
HTTP/3 Frame
  • Type: HEADERS Frame
  • Length: ...
  • Payload: QPACK compressed headers
QUIC Frame
  • Type: STREAM Frame (more types)
  • Stream ID: 2
  • Offset: 500
  • Length: 450 (bytes of QUIC frm data)
  • Data: HTTP/3 Frame Bytes
  • FIN: 1
HTTP/3 Frame
  • Type: DATA Frame
  • Length: ...
  • Payload: data
QUIC Frame
  • Type: STREAM Frame (more types)
  • Stream ID: 4
  • Offset: 0
  • Length: 200 (bytes of QUIC frm data)
  • Data: HTTP/3 Frame Bytes
  • FIN: 0
HTTP/3 Frame
  • Type: HEADERS Frame
  • Length: ...
  • Payload: QPACK compressed headers
lost
QUIC Packet
  • Packet Number: 2
  • Payload: QUIC Frames
  • ...other packet headers
(carries 1 frame)
QUIC Frame
  • Type: STREAM Frame (more types)
  • Stream ID: 4
  • Offset: 200
  • Length: 800 (bytes of QUIC frm data)
  • Data: HTTP/3 Frame Bytes
  • FIN: 1
HTTP/3 Frame
  • Type: DATA Frame
  • Length: ...
  • Payload: data
QUIC Packet
  • Packet Number: 3
  • Payload: QUIC Frames
  • ...other packet headers
(carries 1 frame)
QUIC Frame
  • Type: STREAM Frame (more types)
  • Stream ID: 6
  • Offset: 0
  • Length: 150 (bytes of QUIC frm data)
  • Data: HTTP/3 Frame Bytes
  • FIN: 1
HTTP/3 Frame
  • Type: HEADERS Frame
  • Length: ...
  • Payload: QPACK compressed headers
StreamRequestStatus
stream 2POST /signupdelivered to application
stream 4POST /orderswaiting on fin
stream 6GET /index.htmldelivered to application

Packet 3 arrives even though it was sent after the now-lost packet 2, and QUIC hands stream 6 straight to the application: nothing about it depends on stream 4. Stream 2 was already complete from packet 1 alone. Only stream 4 is stuck, because its HEADERS frame arrived with FIN: 0, telling QUIC more data for that stream is coming, and the DATA frame that would set FIN: 1 never showed up. Arrival order stopped mattering the moment each frame carried its own stream ID.


The pattern across three generations of HTTP

Each generation of head-of-line blocking got solved the same way: by adding a custom identifier at whichever layer had the ordering problem.

HTTP/1.1: no identifier at all
            -> order of arrival is the only way to match request to response
            -> one slow request blocks everything queued after it

HTTP/2:   stream ID added at the application layer
            -> responses can return in any order
            -> but TCP underneath still only understands one ordered byte run,
               so a lost segment blocks every stream anyway

HTTP/3:   stream ID added at the transport layer (inside QUIC)
            -> the layer that actually does loss recovery and retransmission
               now knows which bytes belong to which logical request
            -> a lost packet only blocks the stream it was carrying

HTTP/2's mistake, if you can call it that, was solving the identifier problem one layer too high. It fixed ordering at the application layer while the transport layer underneath still had none. HTTP/3 does not repeat that: it puts the identifier at the same layer that does the retransmitting, so the layer that decides "hold this back" is the same layer that knows what can safely be let through.


Summary