HTTP/3 is HTTP semantics carried over QUIC. QUIC provides encrypted, reliable, independent streams over UDP; HTTP/3, specified in RFC 9114, decides what each stream is for and what bytes go on it. Most explanations stop at the QUIC layer: the handshake, connection IDs and loss recovery. Those are covered in QUIC Architecture, in depth and the overall design in HTTP/3 and QUIC Architecture in Depth.
This page is about the HTTP/3 layer itself, the part you meet when a request fails with H3_REQUEST_REJECTED, a connection dies with H3_CLOSED_CRITICAL_STREAM, or a capture shows bytes you have to read. It covers the streams HTTP/3 opens, the control stream and its settings, the frame format, how a request lives and dies, graceful shutdown and the error codes, with Python that parses real bytes and a short debugging toolkit.
From one TCP byte stream to many QUIC streams
HTTP/2 multiplexed requests as frames, each carrying a stream ID, inside one TCP connection, so a lost packet held back every request behind it. QUIC moves streams into the transport: each stream is ordered on its own, and a loss stalls only the stream it belongs to. HTTP/3 therefore drops HTTP/2's stream IDs in frames entirely. The stream a frame arrives on is the request it belongs to.
QUIC stream IDs encode who opened the stream and in which direction in their two low bits. Client-initiated bidirectional streams are 0, 4, 8 and so on; client-initiated unidirectional streams are 2, 6, 10; server-initiated unidirectional streams are 3, 7, 11. HTTP/3 uses client bidirectional streams for requests, one request per stream, and unidirectional streams for everything else. Server-initiated bidirectional streams have no meaning in HTTP/3; a client that receives one treats it as a connection error unless an extension has defined them.
The unidirectional streams and the control stream
A unidirectional stream starts with a variable-length integer naming its type. RFC 9114 defines type 0x00 for the control stream and 0x01 for server push streams, and RFC 9204 adds 0x02 and 0x03 for the QPACK encoder and decoder streams. Types of the form 0x1f * N + 0x21 are reserved for greasing: endpoints send them at random so that peers learn to ignore unknown types, which keeps the protocol open to extensions. A receiver that sees an unknown stream type must abort reading that stream and otherwise carry on.
Each side must open exactly one control stream at the start of the connection, and the first frame on it must be SETTINGS. A missing or late SETTINGS is the connection error H3_MISSING_SETTINGS; a second control stream is H3_STREAM_CREATION_ERROR, and closing a control stream at any point is H3_CLOSED_CRITICAL_STREAM. The same rule applies to the QPACK streams, so a terminating proxy or library that resets one unidirectional stream kills every request on the connection.
Settings in HTTP/3 are sent once and never changed. RFC 9114 defines SETTINGS_MAX_FIELD_SECTION_SIZE (0x06), an advisory limit on header size, and RFC 9204 defines SETTINGS_QPACK_MAX_TABLE_CAPACITY (0x01) and SETTINGS_QPACK_BLOCKED_STREAMS (0x07). Extensions add more, such as the setting that enables extended CONNECT for WebSockets. HTTP/2 settings that have no HTTP/3 meaning, like initial window size, are reserved, and receiving one is H3_SETTINGS_ERROR. Flow control and the stream count limit moved to QUIC transport parameters, which are exchanged in the handshake.
Frames and variable-length integers
Every HTTP/3 frame is a type, a length and a payload, and both type and length are QUIC variable-length integers. A varint uses the top two bits of its first byte to give its total size, 1, 2, 4 or 8 bytes, and stores the value in the remaining 6, 14, 30 or 62 bits. So 0x25 is 37 in one byte, and 0x7bbd is 15293 in two. Values below 64, which covers every frame type in the base protocol, take one byte.
def enc(v: int) -> bytes:
"""QUIC variable-length integer: the top two bits give the length."""
if v < 1 << 6: return v.to_bytes(1, "big")
if v < 1 << 14: return (v | 0x4000).to_bytes(2, "big")
if v < 1 << 30: return (v | 0x8000_0000).to_bytes(4, "big")
if v < 1 << 62: return (v | 0xC000_0000_0000_0000).to_bytes(8, "big")
raise ValueError("varint out of range")
def dec(buf: bytes, i: int = 0):
n = 1 << (buf[i] >> 6) # 1, 2, 4 or 8 bytes
v = buf[i] & 0x3F
for b in buf[i + 1:i + n]:
v = (v << 8) | b
return v, i + n
assert dec(bytes.fromhex("25"))[0] == 37
assert dec(bytes.fromhex("7bbd"))[0] == 15293
assert dec(bytes.fromhex("9d7f3e7d"))[0] == 494878333The frame types are few. DATA (0x00) carries body bytes and HEADERS (0x01) carries a QPACK-compressed field section; both appear on request streams. SETTINGS (0x04), GOAWAY (0x07), MAX_PUSH_ID (0x0d) and CANCEL_PUSH (0x03) belong on the control stream, and PUSH_PROMISE (0x05) on request streams. The HTTP/2 types with no HTTP/3 equivalent, 0x02, 0x06, 0x08 and 0x09 (PRIORITY, PING, WINDOW_UPDATE and CONTINUATION), are reserved, and receiving one is a connection error. A frame on the wrong stream, such as DATA on the control stream, is H3_FRAME_UNEXPECTED. Unknown frame types, including greased ones, must be ignored. The parser below applies those rules to the bytes of a real client control stream.
FRAMES = {0x00: "DATA", 0x01: "HEADERS", 0x03: "CANCEL_PUSH", 0x04: "SETTINGS",
0x05: "PUSH_PROMISE", 0x07: "GOAWAY", 0x0D: "MAX_PUSH_ID"}
HTTP2_ONLY = {0x02, 0x06, 0x08, 0x09} # reserved: receiving one is an error
SETTINGS = {0x01: "QPACK_MAX_TABLE_CAPACITY", 0x06: "MAX_FIELD_SECTION_SIZE",
0x07: "QPACK_BLOCKED_STREAMS"}
def frames(buf: bytes):
i = 0
while i < len(buf):
ftype, i = dec(buf, i)
length, i = dec(buf, i)
payload, i = buf[i:i + length], i + length
if ftype in HTTP2_ONLY:
raise ValueError("H3_FRAME_UNEXPECTED")
yield FRAMES.get(ftype, f"unknown 0x{ftype:x} (ignored)"), payload
def parse_settings(payload: bytes):
i, out = 0, {}
while i < len(payload):
k, i = dec(payload, i)
v, i = dec(payload, i)
out[SETTINGS.get(k, hex(k))] = v
return out
control = bytes.fromhex("00 04 0a 01 50 00 06 80 00 40 00 07 10")
stype, i = dec(control) # 0x00: this is a control stream
for name, payload in frames(control[i:]):
print(name, parse_settings(payload) if name == "SETTINGS" else payload.hex())
# SETTINGS {'QPACK_MAX_TABLE_CAPACITY': 4096, 'MAX_FIELD_SECTION_SIZE': 16384,
# 'QPACK_BLOCKED_STREAMS': 16}
Worked example: reading a control stream byte by byte
The bytes in the parser are 00 04 0a 01 50 00 06 80 00 40 00 07 10. The first byte, 0x00, is the stream type: a control stream. Next is a frame: type 0x04, SETTINGS, then length 0x0a, ten bytes of payload. The payload is three identifier and value pairs. Identifier 0x01, QPACK table capacity, has the value 50 00: the first two bits are 01, meaning a two-byte varint, and the remaining 14 bits are 0x1000, which is 4096. Identifier 0x06, maximum field section size, has 80 00 40 00: prefix 10, a four-byte varint, value 0x4000, which is 16384, one past the two-byte maximum. Identifier 0x07, blocked streams, is 0x10, which is 16, in one byte.
Now suppose the server later sends 07 01 08 on its control stream. That is GOAWAY with a one-byte payload carrying stream ID 8. It means requests on streams 0 and 4 may have been processed, and requests on 8 and above were not and will not be. The client can retry the latter safely on a new connection, even if they are not idempotent, and must wait for the former to finish or fail.
The life of a request
The client opens the next bidirectional stream and sends a HEADERS frame containing the pseudo-headers :method, :scheme, :authority and :path plus ordinary fields, then zero or more DATA frames, optionally a trailing HEADERS frame with trailers, and finally the QUIC stream FIN. The server answers on the same stream: zero or more interim 1xx responses, each a HEADERS frame, then the final HEADERS, DATA frames, optional trailers and FIN. There is no explicit end-of-message frame and no stream ID inside frames; the FIN is the end. Field names must be lowercase, and connection-specific fields such as Connection and Transfer-Encoding are forbidden. A message that breaks these rules is malformed and gets H3_MESSAGE_ERROR.
Field sections are compressed with QPACK, which uses its encoder and decoder streams to keep a dynamic table in sync without blocking streams in order. The architecture article explains QPACK; at this layer you only need to know that a header block can wait for a dynamic table update, up to the blocked-streams limit the peer advertised.
Cancellation, rejection and graceful shutdown
Requests end early through QUIC's stream reset frames carrying an HTTP/3 error code. A client that no longer wants a response, for example because the user navigated away, sends STOP_SENDING and resets its sending side with H3_REQUEST_CANCELLED. A server that sheds load without doing any work resets the stream with H3_REQUEST_REJECTED. The RFC forbids using H3_REQUEST_REJECTED for a request that was partly or fully processed, so a client may retry a rejected request on a new connection even if it is a POST. A request cut off by the peer's FIN arriving too soon gets H3_REQUEST_INCOMPLETE.
To shut a connection down cleanly, a server sends GOAWAY with the first client stream ID it will not process. Because requests may be in flight while the frame travels, the recommended pattern is two-step: first send GOAWAY with the largest possible value, 2^62-4 for a server, which stops new requests without refusing any already sent; wait about one round trip; then send a second GOAWAY with the real cut-off. Identifiers in later GOAWAY frames may not increase. Finish the accepted requests, then close the connection with H3_NO_ERROR. This is how a deploy drains servers without failing requests; the production runbook covers the fleet side.
Error codes worth knowing
| Code | Value | Meaning | What to do |
|---|---|---|---|
H3_NO_ERROR | 0x0100 | Clean close | Nothing |
H3_CLOSED_CRITICAL_STREAM | 0x0104 | A control or QPACK stream was closed | Look for a library or proxy resetting streams |
H3_FRAME_UNEXPECTED | 0x0105 | Frame on the wrong stream or in the wrong state | Interop bug; capture and compare implementations |
H3_EXCESSIVE_LOAD | 0x0107 | Peer behaving in a way that looks abusive | Check client request and reset rates |
H3_MISSING_SETTINGS | 0x010a | Control stream did not start with SETTINGS | Interop bug |
H3_REQUEST_REJECTED | 0x010b | Request not processed | Safe to retry on a new connection |
H3_REQUEST_CANCELLED | 0x010c | Response no longer wanted | Normal for navigations and timeouts |
H3_MESSAGE_ERROR | 0x010e | Malformed HTTP message | Check field names and forbidden headers |
H3_VERSION_FALLBACK | 0x0110 | Retry the request over HTTP/1.1 | Client should retry over TCP |
Codes are carried in QUIC's RESET_STREAM, STOP_SENDING and CONNECTION_CLOSE frames, so they appear in captures and in qlog traces, not inside HTTP/3 frames.
Debugging toolkit
curl built with an HTTP/3 backend offers two flags: --http3-only fails if HTTP/3 cannot be used, which is what you want when testing the server, and --http3 tries HTTP/3 and falls back to older versions, which is what browsers do. If the fallback succeeds and the strict form fails, UDP is probably blocked on the path.
# Force HTTP/3 (fail rather than fall back) and show the protocol used
curl --http3-only -sv -o /dev/null https://example.com/ 2>&1 | grep -i -E "HTTP/3|alt-svc"
# Allow fallback, as browsers do
curl --http3 -sI https://example.com/
# Decrypt a capture in Wireshark: export TLS secrets, then point
# Preferences > Protocols > TLS > (Pre)-Master-Secret log at the file
SSLKEYLOGFILE=/tmp/keys.log curl --http3-only -s -o /dev/null https://example.com/Because QUIC encrypts nearly everything, a capture is unreadable without keys. Tools that honour SSLKEYLOGFILE write the TLS secrets, and Wireshark uses them to decrypt QUIC and dissect the HTTP/3 frames, so you can see the stream types, SETTINGS and the error code in each reset. QUIC libraries can also write qlog event logs, which visual tools turn into per-stream timelines. Record the UDP path itself too: the UDP guide covers receive buffers and offloads, which cause many HTTP/3 performance problems that look like protocol bugs.
Failure modes and trade-offs
- Proxy closes unidirectional streams. Every request on the connection fails with
H3_CLOSED_CRITICAL_STREAM. Test intermediaries with greased stream types. - Implementations that reject greasing. A peer that errors on a reserved frame type or setting breaks against conforming implementations. Fix the peer; do not disable greasing.
- Non-idempotent retries. Only
H3_REQUEST_REJECTEDand requests above aGOAWAYcut-off are safe to replay. Retrying a request reset with any other code can duplicate side effects. - Single-step GOAWAY. Sending the real cut-off immediately refuses requests already in flight; use the two-step pattern.
- Header limits.
SETTINGS_MAX_FIELD_SECTION_SIZEis advisory; servers still need their own limits on decoded header size.
The overall trade-off: HTTP/3 swaps one inspectable byte stream for independent streams without head-of-line blocking, at the cost of six critical streams, full encryption and debugging that needs keys and QUIC-aware tools. Compare HTTP/2, in depth, where the same features sit inside one TCP connection.
What to do next
- Run the varint and frame parser above against a decrypted capture of your own server's control stream and check its SETTINGS.
- Test your server with curl using --http3-only, and from a network that blocks UDP using --http3, to confirm the fallback works.
- Make sure your client stack retries only on H3_REQUEST_REJECTED or above a GOAWAY cut-off, and never replays other resets.
- Implement or verify two-step GOAWAY draining in your deploy process.
- Add the H3 error code to request logs and dashboards so a rise in CLOSED_CRITICAL_STREAM or EXCESSIVE_LOAD is visible.
- Read RFC 9114 sections 4 to 8; they are short and resolve most interoperability arguments.