§1.3
Chapter 01 · Foundations of QUIC and HTTP3

§1.3HTTP3 Mapping to QUIC Streams and Frames

RFC 9114RFC 9000

Learning objective. Show how HTTP/3 maps request/response semantics onto QUIC streams and a small set of frames, and how that mapping differs from HTTP/2, so the detailed frame and QPACK chapters have a skeleton to hang on.

HTTP/3 is a mapping, not a new transport #

Almost everything hard about moving many requests over one connection, QUIC already solved at the transport layer: multiplexing, ordering, flow control, loss recovery. HTTP/3's job is therefore modest: assign HTTP's messages to QUIC streams, define a few frame types to carry headers and bodies, and specify a compression scheme (QPACK) that survives out-of-order delivery. Compared with HTTP/2, whose framing layer had to invent its own streams, priorities, and per-stream flow control on top of one TCP byte stream, HTTP/3 deletes most of that machinery because the transport provides it ([RFC 9114 §3]).

Streams: one request per bidirectional stream #

Each HTTP request/response exchange uses one client-initiated bidirectional stream. The client sends its request frames on the stream; the server sends the response frames back on the same stream; the stream then closes ([RFC 9114 §4.1]). Because client-initiated bidirectional stream IDs have their low two bits set to 0x00 (constants.md §5), successive requests use stream IDs 0, 4, 8, 12, …. Concurrency is just "open another stream," bounded by the peer's initial_max_streams_bidi limit (§4.2).

Two kinds of unidirectional streams carry connection-wide state rather than a single message:

  • a control stream (type 0x00), opened once in each direction, whose first frame MUST be SETTINGS ([RFC 9114 §6.2.1]);
  • the QPACK encoder and decoder streams (types 0x02 and 0x03), which carry dynamic-table updates and their acknowledgments ([RFC 9204 §4.2], and Chapter 6).

A one-byte varint at the start of each unidirectional stream declares its type (constants.md §9).

ServerClientunidirectional control + QPACKstreams (opened once)one request = one client-initiatedbidirectional streamQPACK table updates ride theencoder/decoder streams (0x02 / 0x03)Control stream (0x00): SETTINGSControl stream (0x00): SETTINGSStream 0: HEADERS(QPACK-encoded:method,:path,…)Stream 0: DATA (request body, ifany)Stream 0: HEADERS (:status,response fields)Stream 0: DATA (response body)
Fig. 1.3-1A request is one bidirectional stream carrying HEADERS then DATA; connection-wide SETTINGS and QPACK updates travel on separate unidirectional streams.RFC 9114 §4.1, §6.2

Frames: a deliberately small set #

An HTTP/3 frame is simply a varint Type, a varint Length, and a payload ([RFC 9114 §7.1]) — there is no per-frame stream identifier, because the QUIC stream the frame arrives on already provides that context. The core types (full list in constants.md §10) are:

Type Frame Role
0x00 DATA message body bytes
0x01 HEADERS QPACK-encoded header/trailer field section
0x04 SETTINGS connection configuration (control stream only)
0x03 CANCEL_PUSH abandon a server push
0x05 PUSH_PROMISE promise a pushed response
0x07 GOAWAY graceful shutdown
0x0d MAX_PUSH_ID raise the server's push allowance

Several HTTP/2 frames have no HTTP/3 equivalent because QUIC absorbs their function: there is no PING (QUIC has PING frames), no WINDOW_UPDATE (QUIC does flow control), and no RST_STREAM frame (QUIC's RESET_STREAM resets the stream) ([RFC 9114 §7.2.8]). Priority is likewise not an HTTP/3 frame; it rides an HTTP header field and an optional PRIORITY_UPDATE frame per RFC 9218 (§4.3).

HTTP/3 framing vs HTTP/1.1 messages on the wire #

The semantics are the same HTTP the web has always used, but the encoding is unrecognizable. HTTP/1.1 is a text protocol: a request is a request line plus CRLF-terminated header lines, an empty line, then an optional body whose end is found from Content-Length or Transfer-Encoding: chunked.

HTTP/1.1 — GET, then POST (text, CRLF-delimited)HTTP
GET /index.html HTTP/1.1
Host: example.org

POST /submit HTTP/1.1
Host: example.org
Content-Type: application/json
Content-Length: 27

{"name":"ada","role":"eng"}

The same GET in HTTP/3 is the bytes below: a QPACK-compressed HEADERS frame carried in a STREAM frame, shown decrypted (on the wire the whole payload is AEAD-encrypted). Indentation marks the nesting.

HTTP/3 — the same GET, decrypted frame bytesTEXT
0b 00 20                    STREAM frame: type 0x0b, stream 0, Length 32
   01 1e                    HEADERS frame: type 0x01, Length 30
      00 00                 QPACK prefix: Required Insert Count 0, Base 0
      d1                    :method: GET        (indexed, static 17)
      d7                    :scheme: https      (indexed, static 23)
      50 0b "example.org"   :authority: example.org (static name 0 + literal)
      51 0b "/index.html"   :path: /index.html      (static name 1 + literal)

The HTTP/1.1 request line and Host header spend 47 bytes of text; the HTTP/3 HEADERS frame is 32 bytes, and only the two literal values (example.org and /index.html) survive as readable bytes, because QPACK replaced every field name and the common values (GET, https) with a one-byte static-table index. HTTP/3 keeps the meaning and discards the text. The differences that matter on the wire:

Aspect HTTP/1.1 HTTP/3
Encoding ASCII text, CRLF-delimited binary frames: varint Type + Length + payload
Request/status line GET /path HTTP/1.1 pseudo-headers :method, :path, :scheme, :authority (:status for responses)
Header fields full text, repeated every message QPACK-compressed field lines, lowercase names (Ch 6)
Body delimiting Content-Length or chunked + the blank line one or more DATA frames, each with an explicit Length; stream FIN ends it
Chunked transfer Transfer-Encoding: chunked does not exist — DATA frames already self-delimit
Concurrency one message at a time per connection (pipelining rarely used) many concurrent streams (§1.1)

The Host header becomes the :authority pseudo-header; the request line's method and path become :method and :path. Two whole classes of problem disappear with the text: there is no ambiguity between Content-Length and chunked framing (a common request-smuggling vector), and there is no header-line parsing to disagree about.

Worked example: the shape of a GET #

A client issuing GET /index.html opens stream 0 and writes a single HEADERS frame whose payload is the QPACK encoding of the pseudo-header fields :method: GET, :scheme: https, :authority: example.org, :path: /index.html. With no request body, that lone HEADERS frame (followed by the QUIC FIN bit that ends the stream in the send direction) is the entire request.

Figure 1.3-2 shows that request the way it actually travels: a stack of nested containers, read top-down. The QUIC 1-RTT packet carries a STREAM frame (type 0x0b, STREAM with a Length and the FIN flag) on stream 0; the frame's Stream Data is one HTTP/3 HEADERS frame (type 0x01, Length 30); and the HEADERS payload is a 30-byte QPACK field section. Each pseudo-header resolves against QPACK's static table (constants.md §14; RFC 9204 Appendix A): :method: GET and :scheme: https compress to single indexed bytes (0xd1 and 0xd7, static indices 17 and 23) while :authority and :path reuse a static name with a literal value, so :path: /index.html encodes as 0x51 0x0b followed by the eleven ASCII bytes of /index.html. Thirty bytes of headers, wrapped in two bytes of HTTP/3 framing, wrapped in three bytes of STREAM framing, inside one encrypted packet.

L1QUIC1-RTT packet · short headereverything after the header is AEAD-encrypted on the wire
01911byte offsetHeaderByte0x418 b · 1 BDest. Connection ID8 B (example)64 b · 8 BPacket Number1-4 B16 b · 2 BFrame PayloadSTREAM frame0..*
L2QUICSTREAM frame · type 0x0b0x08 base | 0x02 LEN | 0x01 FIN (offset 0, so OFF omitted)
0123byte offsetType0x0b8 b · 1 BStreamID0x00stream 08 b · 1 BLength0x2032 B8 b · 1 BStream DataHTTP/3 frames0..*
L3HTTP/3HEADERS frame · type 0x01Type + Length + payload (RFC 9114 §7.1)
012byte offsetType0x01HEADERS8 b · 1 BLength0x1e30 B8 b · 1 BEncoded Field SectionQPACK0..*
L4QPACKfield section · 30 bytesstatic table only; no dynamic table, no Huffman
023417byte offsetPrefix00 00RIC=0 · Base=016 b · 2 B:method:GET0xd1idx 178 b · 1 B:scheme:https0xd7idx 238 b · 1 B:authority: example.org50 0b 65…name 0 · 13 B104 b · 13 B:path: /index.html51 0b 2f…name 1 · 13 B104 b · 13 B
Fixed-width fieldVariable-length integer (varint)Length / count prefixVariable-length valueAEAD-protected / encrypted
Fig. 1.3-2GET /index.html on the wire — a HEADERS frame nested through QUICRFC 9000 §19.8; RFC 9114 §7.2.2; RFC 9204 §4.5

The server replies on the same stream with a HEADERS frame (:status: 200 plus response fields) and one or more DATA frames carrying the body. Frame order matters and is checked: an unexpected frame on a request stream is a connection error H3_FRAME_UNEXPECTED (0x0105), covered in §5.4.

Worked example: the shape of a POST #

A POST is the same skeleton with one addition that changes the picture: it has a body, so the HEADERS frame is followed by one or more DATA frames on the same stream. Where the GET was a lone HEADERS frame, the POST is a two-frame sequence.

L1QUICSTREAM frame · type 0x0bon stream 4; carries the whole request, FIN set (wrapped in a 1-RTT packet as Fig 1.3-2)
0123byte offsetType0x0b8 b · 1 BStreamID0x04stream 48 b · 1 BLength0x3e62 B8 b · 1 BStream Datatwo HTTP/3 frames0..*
L2HTTP/3Stream Data · HEADERS then DATAa GET ends after HEADERS; a POST adds a DATA frame for the body
033byte offsetHEADERS frame01 1f …0x01 · 33 B264 b · 33 BDATA frame00 1b …0x00 · 29 B232 b · 29 B
L3HTTP/3DATA frame · type 0x00Type + Length + raw body — no chunking, no CRLF terminators
012byte offsetType0x00DATA8 b · 1 BLength0x1b27 B8 b · 1 BBodyrequest body0..*
L4app dataRequest body · application/json (27 B)
0byte offset{"name":"ada","role":"eng"}UTF-8 bytes216 b · 27 B
Variable-length integer (varint)Length / count prefixVariable-length value
Fig. 1.3-3POST /submit on the wire — a HEADERS frame followed by a DATA frameRFC 9114 §7.2.1, §7.2.2

The HEADERS frame carries the same four pseudo-headers, now with :method: POST (static index 20, so a single indexed byte 0xd4) and two body-describing fields a GET lacks. Both compress well: content-type: application/json is a complete entry in the QPACK static table (index 46), so it is one indexed byte 0xee; content-length: 27 reuses the static name at index 4 with the literal value 27, encoding as 0x54 0x02 0x32 0x37. The DATA frame that follows is minimal — type 0x00, a varint Length of 0x1b (27), then the 27 body bytes verbatim ({"name":"ada","role":"eng"}). The stream's FIN bit, set on that last DATA frame, marks the end of the request; there is no Content-Length-versus-chunked bookkeeping, because the frame's Length already says exactly how many body bytes follow.

Note

Response bodies work identically: a :status HEADERS frame followed by DATA frames. A large response is simply split across multiple DATA frames (each self-delimiting), which is what replaces HTTP/1.1 chunked transfer-encoding. Trailers, when present, are a second HEADERS frame after the DATA frames.

Note

The mapping "one request = one bidirectional stream" is what makes HTTP/3 free of transport head-of-line blocking: a lost packet affecting stream 4 does not delay a fully arrived response on stream 8. The residual blocking that remains lives in header decompression, not in the stream mapping — that is QPACK's problem, taken up in §6.3.

In practice

editorial Two ordering rules cause most early interop failures: SETTINGS must be the first frame on each control stream, and the control and QPACK streams must be opened (and their type byte sent) before they are relied upon. Endpoints also emit reserved "GREASE" frame and stream types (0x1f * N + 0x21) to keep peers tolerant of the unknown; a correct implementation ignores them (§13.2).

Takeaways #

HTTP/3 rides the transport it was designed for: requests become streams, a handful of frames carry headers and bodies, and connection-wide settings and compression state get their own unidirectional streams. With this skeleton in place, §1.4 descends to the packet on the wire — how these stream bytes are framed, identified, and multiplexed inside QUIC packets.