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
0x02and0x03), 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).
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.
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.
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.
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.
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.
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.
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.
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.