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.

Advertisement

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.

One HTTP/3 connection: request streams plus six unidirectional streams that run the protocolClientServerBidi stream 0, 4, 8 ...one request each: HEADERS, DATA, FINClient control (type 0x00)SETTINGS first, then GOAWAYClient QPACK encoder 0x02 / decoder 0x03Server control 0x00, QPACK 0x02 / 0x03Client unidirectional IDs are 2, 6, 10 ...; server unidirectional IDs are 3, 7, 11 ...Losing a control or QPACK stream is fatal: H3_CLOSED_CRITICAL_STREAMLosing a request stream affects only that request: RESET_STREAM with an H3_ error codePacket loss on one stream stalls only that stream; TCP would stall all of themUnknown stream types, frame types and settings are ignored, which is how extensions deploy
Figure 1. Requests use client bidirectional streams. Each side also opens a control stream and, for QPACK, an encoder and a decoder stream, all unidirectional and all critical.

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.

Advertisement

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] == 494878333

The 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

CodeValueMeaningWhat to do
H3_NO_ERROR0x0100Clean closeNothing
H3_CLOSED_CRITICAL_STREAM0x0104A control or QPACK stream was closedLook for a library or proxy resetting streams
H3_FRAME_UNEXPECTED0x0105Frame on the wrong stream or in the wrong stateInterop bug; capture and compare implementations
H3_EXCESSIVE_LOAD0x0107Peer behaving in a way that looks abusiveCheck client request and reset rates
H3_MISSING_SETTINGS0x010aControl stream did not start with SETTINGSInterop bug
H3_REQUEST_REJECTED0x010bRequest not processedSafe to retry on a new connection
H3_REQUEST_CANCELLED0x010cResponse no longer wantedNormal for navigations and timeouts
H3_MESSAGE_ERROR0x010eMalformed HTTP messageCheck field names and forbidden headers
H3_VERSION_FALLBACK0x0110Retry the request over HTTP/1.1Client 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_REJECTED and requests above a GOAWAY cut-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_SIZE is 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

  1. Run the varint and frame parser above against a decrypted capture of your own server's control stream and check its SETTINGS.
  2. Test your server with curl using --http3-only, and from a network that blocks UDP using --http3, to confirm the fallback works.
  3. Make sure your client stack retries only on H3_REQUEST_REJECTED or above a GOAWAY cut-off, and never replays other resets.
  4. Implement or verify two-step GOAWAY draining in your deploy process.
  5. Add the H3 error code to request logs and dashboards so a rise in CLOSED_CRITICAL_STREAM or EXCESSIVE_LOAD is visible.
  6. Read RFC 9114 sections 4 to 8; they are short and resolve most interoperability arguments.
Key takeaway: HTTP/3 puts each request on its own client bidirectional QUIC stream and runs the protocol on six unidirectional streams: a control stream and two QPACK streams per side, all critical. Frames are a varint type, a varint length and a payload; SETTINGS comes first and only once, unknown types are ignored, and HTTP/2-only frames are errors. Requests end with FIN or a reset carrying an H3 code, only REJECTED requests and those above a GOAWAY cut-off are safe to replay, and graceful shutdown uses two GOAWAY frames. With a key log and a small parser, every one of these behaviours is visible in the bytes.