HTTP/3

32 min read
On this page

HTTP/3 maps HTTP semantics to QUIC, utilizing the reliable streams, flow control, congestion control, and TLS 1.3 handshake provided by QUIC. It reduces the impact of transmission gaps in one stream on the ordered delivery of other streams, but does not eliminate all cross-request dependencies.

Methods, status codes, caching, and other aspects inherit general HTTP semantics; the 9-byte frame headers, stream window updates, and priority trees of HTTP/2 are not carried over verbatim. Troubleshooting should distinguish between the HTTP layer, QUIC streams, QUIC packets, and UDP datagrams.

IssueKey Boundary
Does UDP packet loss mean HTTP content is lost?QUIC is responsible for reliable stream data recovery
Are streams completely independent of each other?Ordered delivery is independent, but congestion, quotas, and upper-layer dependencies are still shared
What if QPACK table updates haven't arrived?Field blocks referencing unreceived table entries may cause blocking
Where is the Stream ID in HTTP/3 frames?The association is provided by the outer QUIC stream; it is not redundantly encoded in the HTTP frame header
Must the first access go through HTTP/2?Endpoints can also be discovered via DNS HTTPS records, etc.
Does switching networks or using 0-RTT guarantee faster speeds?It depends on the path, state, policy, and actual requests

Stream Mapping: Division of Labor for Requests, Control, and Compression Instructions

HTTP/3 assigns different responsibilities to different QUIC streams Request Streams: Client-initiated bidirectional streams 0, 4, 8... One request and response per stream; HEADERS / DATA Control Streams: One unidirectional stream per endpoint Stream type 0x00; SETTINGS first, then other applicable control frames QPACK Streams: Encoder and Decoder each have one unidirectional stream Stream types 0x02 / 0x03; Table update and acknowledgment instructions QUIC Stream ID and HTTP/3 unidirectional stream type are different fields; type 0x00 does not mean the QUIC stream ID is 0.

Requests use client-initiated bidirectional QUIC streams with IDs 0, 4, 8, etc.; requests and responses occupy the two directions of that stream. Unlike HTTP/2, QUIC stream 0 can carry application requests; it is not a dedicated stream for connection control frames.

Unidirectional streams start with the stream type, followed by the content specified for that type. The control stream for each endpoint begins with SETTINGS as the first frame, and SETTINGS cannot be sent again on the same control stream; there is no SETTINGS ACK as in HTTP/2. QPACK encoder and decoder streams carry their respective instruction formats, not table updates wrapped in HTTP HEADERS or DATA frames.

Both parties must allow the peer to establish at least three unidirectional streams, leaving space and flow control budget for control and QPACK. If critical established streams are unexpectedly closed, it results in a connection error; do not treat this as the natural closure after completing a regular request. RFC 9114: Stream Mapping

Frame Headers are Variable-Length Integers, No Longer Fixed at 9 Bytes

HTTP/3 frames consist of two QUIC variable-length integers (Type, Length) and the payload. The length describes only the payload; the stream ID is provided by the outer QUIC stream. General frame integers can use encoding lengths allowed by the protocol; this example selects the shortest encoding.

Value RangeEncoded BytesTop Two Bits
0 to 63100
64 to 16,383201
16,384 to 1,073,741,823410
Up to 2^62−1811

An HTTP/3 frame with 5 B of DATA is Type (1 B) + Length (1 B) + Content (5 B), totaling 7 B. For 1000 B of DATA, it is 1+2+1000=1003 B; these numbers do not include QUIC STREAM frames, packet protection, or UDP/IP headers, so they cannot be directly used to claim a fixed percentage of end-to-end savings.

def varint(value):
    if not 0 <= value < 2**62:
        raise ValueError('out of range')
    for size, prefix in [(1, 0), (2, 1), (4, 2), (8, 3)]:
        if value < 2**(8 * size - 2):
            return (value | (prefix << (8 * size - 2))).to_bytes(size, 'big')

def decode(data):
    size = 1 << (data[0] >> 6)
    if len(data) < size:
        raise ValueError('truncated')
    return int.from_bytes(data[:size], 'big') & ((1 << (8 * size - 2)) - 1), size

for n in [0, 63, 64, 16383, 16384, 2**30 - 1, 2**30, 2**62 - 1]:
    value, used = decode(varint(n))
    assert value == n and used == len(varint(n))
assert varint(64).hex() == '4040'
small = varint(0) + varint(5) + b'hello'
large = varint(0) + varint(1000) + b'x' * 1000
assert small.hex() == '000568656c6c6f' and len(small) == 7
assert len(large) == 1003
try:
    decode(bytes([0x40]))
except ValueError:
    pass
else:
    raise AssertionError('accepted truncated integer')
print('HTTP/3 DATA complete frame bytes:', len(small), len(large))

Requests or responses use HEADERS, optional DATA, and trailer HEADERS; the end of the direction is expressed by the QUIC stream ending, with no END_STREAM flag as in HTTP/2. HEADERS frames can span multiple QUIC STREAM frames or packets and do not require CONTINUATION; packet boundaries are not field block boundaries.

No TCP Head-of-Line Blocking, but Still Face Shared Resources

Assume stream 0 is missing some content, while stream 4 has received all its data; QUIC can continue to deliver stream 4 without waiting for the gap in stream 0. However, the same lost packet may contain data for multiple streams, and congestion window adjustments caused by packet loss may affect the entire connection's transmission.

Connection-level MAX_DATA, per-stream MAX_STREAM_DATA, the number of openable streams, CPU, and server-side queuing may also limit progress. HTTP/3 delegates flow control to QUIC and no longer sends HTTP/2 WINDOW_UPDATE; unlike HTTP/2 which only charges for DATA payload, QUIC stream quotas cover the bytes of that stream, including HTTP frame headers and HEADERS data.

Therefore, "a specific request is not forced to wait due to byte gaps in another stream" is a concrete guarantee; "requests are completely independent of each other" is an overextension. QUIC Streams and Flow Control

QPACK: Choosing Between Compression Benefits and Blocking Risks

QPACK allows choosing dependencies, but dependencies can also cause limited blocking Encoder Stream: Insert items 6 and 7 Delayed in transit; receiver temporarily inserts only 5 items Request A: Required Insert Count=7 HEADERS arrived, but cannot be decoded yet Request B: No dynamic table references Required Insert Count=0 After table updates arrive Cumulative insert count reaches 7, A can proceed B being independently decodable does not mean there is no shared impact: connection congestion, flow control, CPU, and application resources are still shared.

HPACK relies on the evolution of compression state in an ordered connection; since HTTP/3 streams can be delivered independently, QPACK separates table updates and acknowledgments into dedicated streams. Field blocks may still reference dynamic items that have not yet been received, expressing decoding dependencies via Required Insert Count.

In the diagram, the decoder has cumulatively inserted only 5 items, while Request A requires 7, so it must wait for updates; Request B uses only static items or literals, with Required Insert Count=0, so it can be decoded. This is the restored insert count; online fields also involve specified count encoding and Base/relative indexing, so simple integer comparison should not be treated as a complete decoder.

The QPACK static table has 99 entries, and its indexing is not compatible with HPACK. The dynamic table is still maintained separately for the encoding direction of each connection; the decoder returns information such as Section Acknowledgment, Stream Cancellation, and Insert Count Increment to help the encoder track dependencies and releasable states. The units and release conditions for these instructions differ, so they cannot be collectively referred to as a single "table synchronization."

SETTINGS_QPACK_MAX_TABLE_CAPACITY and SETTINGS_QPACK_BLOCKED_STREAMS default to 0. The allowed number of blocked streams is not "the number of all currently requested streams"; the encoder can optionally choose known-safe references or literals to avoid increasing potential blocking at the cost of lower compression ratios. Never Indexed does not mean encrypted fields. RFC 9204: QPACK

Why can't fields be decoded even when stream bytes are complete?

Deliver all HEADERS for A first; it still waits for the 6th and 7th insertions; deliver B, and it can be decoded immediately. When the count reaches 6, A still does not meet the dependency; it unlocks only when it reaches 7. This waiting occurs due to QPACK dependencies, not due to byte gaps in A's stream.

Preparing the visual
Why can't fields be decoded even when stream bytes are complete?

Reliable stream delivery completion does not equal field decoding completion. Only blocks referencing dynamic items wait for corresponding insertions; static/literal blocks can complete first.

Endpoint Discovery: Alt-Svc and DNS HTTPS

Alt-Svc: h3=":443"; ma=86400 indicates that the original service provides an HTTP/3 alternative service on the same host's port 443; max-age describes the cache time for the alternative service information. It is not the cache duration for HTTP content, nor does it require the client to immediately migrate existing connections to it.

Alternative services do not change the original origin. Even if connecting to a different hostname or port, authentication still requires proof of authority to represent the original service; a single location hint cannot be treated as automatic authorization to trust a new identity. RFC 7838: Alternative Services

DNS HTTPS/SVCB records can also provide service parameters such as alpn and port, helping clients discover h3 before access, without needing to receive Alt-Svc via h1/h2 every time. Records are merely discovery materials; client capabilities, certificate validation, and connection results must still meet requirements. RFC 9460

When UDP is blocked, versions are incompatible, or the path is unreachable, the client may race or fall back to HTTP over TCP. The specific wait duration is determined by the implementation, and it cannot be promised that "attempting QUIC failure causes absolutely no delay." Metrics should be split into initial discovery, connection establishment, fallback, and business processing times.

0-RTT and Migration: Dependence on Prior State and Path Validation

0-RTT requires available resumption state and may be rejected by the server. When sending early requests, appropriate remembered HTTP/3 settings and transport parameters must be used; when rejected, when to resend and whether it can be resent must comply with protocol and business agreements.

Early data carries replay risks. Although GET is nominally safe, it does not prove that the actual application has no side effects; operations such as deductions or order submissions cannot be placed in early data solely because of lower latency. HTTP's Early-Data marker and 425 Too Early can help intermediaries and origin servers coordinate; retries after 425 should not be placed in early data again. RFC 8470

Connection migration can maintain connection identity after address changes, but requires the new path to be available, validated, and connection state to persist, subject to endpoint policies. Switching from Wi-Fi to cellular may experience probing, congestion state adjustments, or failures, and cannot guarantee interruption-free switching every time. Server-side load balancing also needs to route appropriate CIDs to instances holding the connection state. QUIC Migration

Cancellation and GOAWAY: Boundaries Differ from HTTP/2

QUIC's RESET_STREAM, STOP_SENDING, etc., participate in request termination, but "canceling a stream" does not equal business transaction rollback. When there is no response, it is necessary to determine if the request might have already been processed before deciding on business retries.

The request stream IDs in the server-side HTTP/3 GOAWAY represent the "no longer accepted" boundary: requests greater than or equal to it will not be processed, while requests less than it may have already been processed. If the boundary is 8, streams 8 and 12 can be retried on other connections, but streams 0 and 4 cannot be assumed unexecuted based solely on this. HTTP/2's Last-Stream-ID is an inclusive upper bound of potentially processed streams; do not copy it into the same comparison operation.

The boundary in multiple GOAWAYs cannot increase. GOAWAYs issued by the client use push IDs and cannot be directly interpreted as the same client request stream IDs. RFC 9114: Exit

Push, DATAGRAM, and Observability

The base HTTP/3 specification still defines push and corresponding permission controls; whether browser or server products support it should be verified separately; do not directly write that the protocol has deprecated all push. Standard HTTP DATA still travels over reliable streams; QUIC supporting DATAGRAM does not mean all HTTP/3 responses automatically become unreliable transmissions.

Packet capture should first confirm QUIC on UDP, version, and connection establishment, then combine available decryption materials or implementation event tracing to view HTTP frames, QPACK blocking, and flow control; relying solely on UDP port 443 cannot determine request success. Application status codes and QUIC transport closure errors should also be recorded separately to distinguish between business rejections and connection failures.

Further Reading

  • HTTP/2: Frames, multiplexing, HPACK, and exit boundary comparisons.
  • UDP and QUIC: Packet recovery, CID, and flow control.
  • TLS 1.3: Handshake and early data security properties.