Internet Protocols · QUIC and HTTP/3

HTTP/3 Framing: Request Streams, Control Streams, and the Settings Exchange

How HTTP semantics are carried over QUIC streams, which unidirectional streams the connection requires, and why the control channel is a stream rather than a frame type.

HTTP/3 keeps HTTP semantics and discards HTTP/2’s framing layer. Requests and responses are still messages with a method, a status, and a set of header fields, but the transport underneath already provides ordered byte streams, so the machinery HTTP/2 needed to multiplex over a single TCP connection has nothing left to do. What replaces it is a thin per-stream format and a handful of connection-wide unidirectional streams that carry the state a connection needs before it can carry requests.

Selecting HTTP/3 without a handshake of its own

There is no HTTP/3 negotiation at the HTTP layer. The protocol is chosen during the TLS handshake by ALPN, the extension defined in RFC 7301 that lets a client offer a list of protocol identifiers and the server select one. The identifier for HTTP/3 is the three bytes h3, and a connection that selects it is an HTTP/3 connection from its first application packet.

The consequence is that a client cannot discover HTTP/3 support by asking. It learns it out of band, from an Alt-Svc header on a previous HTTP/1.1 or HTTP/2 response or from DNS records, and then attempts a QUIC connection with h3 in the ALPN list. If the server does not support it, the handshake fails with a no-application-protocol alert and the client falls back. Discovery is deliberately outside the protocol, so that a server can advertise HTTP/3 without committing every client to it.

The ALPN identifier also implies a compatibility requirement. A server that supports both HTTP/2 and HTTP/3 must serve the same resources over either, and the version-independent parts of HTTP, the semantics in RFC 9110, are what make that possible. Only the framing and the connection management differ.

Two kinds of stream, five uses

Client-initiated bidirectional streams carry requests. The client’s first request is on stream 0, the second on stream 4, and so on through the client’s bidirectional sequence, because the low two bits of a stream ID fix its type. A bidirectional stream ensures that the response is correlated with the request: the response is written on the same stream, in the opposite direction, so no request identifier is needed.

Unidirectional streams carry connection state and pushes, and each begins with a varint stream type. Type 0x00 is a control stream, type 0x01 is a push stream, and QPACK defines two more for its encoder and decoder instructions. HTTP/3 uses no server-initiated bidirectional streams at all: a client that receives one is required to treat it as a connection error of type H3_STREAM_CREATION_ERROR, unless an extension has been negotiated that gives those streams a meaning.

A receiver that reads an unrecognised stream type must not treat it as an error. The specification requires it either to abort reading the stream or to discard incoming data, and forbids treating an unknown type as a connection error of any kind. That is what lets a future extension use the space without a version bump, and it is the same forward-compatibility reasoning applied to unknown frame types, which must also be ignored where their position is not itself significant.

Exactly one control stream is permitted in each direction, and closing either one is a connection error of type H3_CLOSED_CRITICAL_STREAM. The pair of unidirectional streams, rather than a single bidirectional one, exists so that either endpoint can send its settings as soon as the transport allows, which matters when only one side has 0-RTT keys. The transport parameters must permit the peer to create at least three unidirectional streams, since the control stream and the two QPACK streams are all required, and each should have at least 1,024 bytes of flow-control credit.

SETTINGS is the first frame and only the first

The control stream is a sequence of HTTP/3 frames: a varint type, a varint length, and a payload. The first frame must be SETTINGS, and a control stream whose first frame is anything else is a connection error of type H3_MISSING_SETTINGS. A second SETTINGS frame on the same stream is an error too, because the values are fixed for the life of the connection and never renegotiated.

Each setting is an identifier and a value, both varints. A receiver must ignore any identifier it does not understand, which is the mechanism that lets extensions add settings without coordination. Identifiers of the form 0x1f × N + 0x21 are reserved for exercising exactly that requirement, and an endpoint should include at least one so that a peer which mishandles unknown identifiers fails during interoperability work rather than in deployment. Identifiers that HTTP/2 defined but HTTP/3 has no use for are reserved the other way: sending one, or receiving one, is a connection error of type H3_SETTINGS_ERROR.

Only one setting is defined by the base specification: SETTINGS_MAX_FIELD_SECTION_SIZE, whose default is unlimited. The HTTP/2 settings for concurrent streams and initial window size are absent because the transport now owns those limits, and the setting that enabled push in HTTP/2 is replaced by a frame that grants push credit.

The size a field section is measured against is the uncompressed size, computed as the length of each name plus the length of each value plus 32 bytes of overhead per field. A block that compresses to a few hundred bytes can therefore exceed a limit expressed in thousands, and the check must be applied to the decoded section rather than to the bytes that arrived.

Settings are not negotiated. Each endpoint states what it will accept, and the peer’s behaviour is bounded by that statement. Because packets carrying settings can be lost or delayed, both endpoints begin with default values and may send messages before the peer’s SETTINGS arrives; a client that waits indefinitely for settings before sending its first request gains nothing, and the specification advises it to process incoming datagrams so that the settings are likely to be applied before the request goes out.

The 0-RTT case needs care. A client resuming a session may send a request before the server’s settings are known, so it must comply with the settings the server used in the session being resumed, or with defaults if none were stored. A server that accepts 0-RTT data must not then send settings that the client’s early request would have violated; if it does, the client treats that as H3_SETTINGS_ERROR.

Frames on a request stream

A request is a header section in a single HEADERS frame, optionally content in a series of DATA frames, and optionally a trailer section in a single HEADERS frame. There is no END_STREAM flag on the frames: the end of the message is the end of the stream, signalled by the transport’s FIN. That removes a field HTTP/2 needed and makes the frame header a bare type and length.

The HEADERS payload is a QPACK-encoded field section. The encoding separates the representation of a field from the instruction that inserts it into a table, which is what allows a header block to be decoded without waiting for other streams, as described in QPACK and the cost of a shared header table. Because frames here are carried on a stream rather than packed into a shared connection-level sequence, a HEADERS frame may span multiple transport packets, and there is no CONTINUATION frame.

DATA frames carry content in length-prefixed chunks with no minimum size and no padding defined by the base protocol, so a small body costs a two- or three-byte frame header plus the bytes. Transfer codings do not exist in HTTP/3, and the Transfer-Encoding field must not be used.

The sequence rules are strict, and violations are connection errors of type H3_FRAME_UNEXPECTED rather than stream errors. A DATA frame before any HEADERS, or a HEADERS or DATA frame after the trailing HEADERS, is invalid. A stream that carries more than one request, or more than one final response, is malformed. The severity is deliberate: a peer that has lost track of which section it is in cannot be trusted about where the next one begins, and the connection’s framing state is shared across all its streams.

Cancellation is a transport operation. An endpoint that wants to abandon a request resets the sending direction and aborts reading the receiving direction, and the error code distinguishes the cases. H3_REQUEST_REJECTED means the request was never processed and may be retried; H3_REQUEST_CANCELLED means processing had begun; H3_REQUEST_INCOMPLETE means the client’s stream ended before a complete request had arrived. A server that abandons a partially processed request must not use the rejected code, because a client is entitled to retry on it.

Push, and the credit that gates it

Server push is announced by PUSH_PROMISE on a request stream, carrying the promised request’s field section and a push ID, and fulfilled on a server-initiated unidirectional stream whose header is the push stream type followed by that push ID. The push ID is what links the promise to the response, since the two travel on different streams and cannot be correlated by stream number.

Push is off until the client grants credit. The server cannot push until it has received a MAX_PUSH_ID frame, and a promise that references an ID above the current maximum is a connection error of type H3_ID_ERROR. Each push ID may be used in only one push stream header. CANCEL_PUSH, sent on the control stream, withdraws a promise or abandons one the server has decided not to fulfil, and a client that has already received the push stream should simply stop reading it.

The design differs from HTTP/2 in a way that follows from the transport. HTTP/2 could bound in-flight pushes with the concurrent-stream limit because push used a server-initiated stream from the same numbering space as requests. In HTTP/3 the push stream shares the unidirectional space with the control and QPACK streams, so a stream count would conflate push with connection state; the push ID space is a separate counter and is bounded separately.

What the stream limit now controls

Concurrency is a transport property. The server’s MAX_STREAMS for client-initiated bidirectional streams is the number of requests the client may have open at once, and a server that sets it low serializes work the client wanted to parallelize. The specification recommends permitting at least 100 concurrent request streams so as not to limit parallelism unnecessarily.

A subtlety follows from when a stream counts as closed. QUIC considers a stream closed only once all data has been received and all sent data has been acknowledged, whereas HTTP/2 considered it closed once the END_STREAM flag was committed to the transport. An HTTP/3 stream therefore stays active longer than its HTTP/2 equivalent, and a server aiming for the same effective concurrency may need to grant a larger stream limit than it would have set in HTTP/2.

All frames are flow controlled, not just bodies. Because every HTTP/3 frame travels on a QUIC stream, the frame headers and the control stream’s settings consume credit along with content, which is why the guidance on unidirectional stream credit exists at all. The credit accounting itself is described in two-level flow control, and it applies to the encoder stream as much as to a response body: a peer that stops reading the encoder stream eventually forces the encoder back to literal field representations.

References

  1. RFC 9114: HTTP/3 — IETF
  2. RFC 9110: HTTP Semantics — IETF
  3. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF
  4. RFC 7301: TLS Application-Layer Protocol Negotiation Extension — IETF