Internet Protocols · QUIC and HTTP/3

Stream Identifiers and the State Machine That Makes Closing Cheap

How the low two bits of a QUIC stream ID encode direction and initiator, why IDs are never reused, and how reset and stop-sending terminate a stream without waiting for a FIN.

A stream in QUIC is not an object the connection negotiates. It is a number carried in a frame header, and the number alone tells the receiver which direction the data flows in and which endpoint created it. Opening a stream costs no round trip and no separate handshake: the first frame that mentions a stream number brings it into existence, and the receiver infers everything else from the arithmetic of that number.

Two bits of type, sixty bits of index

Every stream ID is a sixty-two-bit integer. The two least significant bits are a type code, and the remaining sixty bits are the ordinal of the stream within its type.

/* RFC 9000 section 2.1: type is the low two bits of the ID. */
enum stream_type {
    CLIENT_BIDI = 0x0,
    SERVER_BIDI = 0x1,
    CLIENT_UNI  = 0x2,
    SERVER_UNI  = 0x3,
};

static inline uint64_t stream_index(uint64_t id) { return id >> 2; }
static inline int stream_type_of(uint64_t id)   { return (int)(id & 0x3); }

/* Only the initiator may send on a unidirectional stream. */
static inline int may_send(uint64_t id, int is_client)
{
    int t = stream_type_of(id);
    return t == CLIENT_BIDI || t == SERVER_BIDI ||
           (t == CLIENT_UNI && is_client) || (t == SERVER_UNI && !is_client);
}

The encoding gives each type its own interleaved sequence: client-initiated bidirectional streams are 0, 4, 8, and so on; server-initiated bidirectional are 1, 5, 9; client unidirectional are 2, 6, 10; server unidirectional are 3, 7, 11. Nothing is negotiated, so a client opening its third bidirectional stream uses ID 8 regardless of what the server has opened. The largest legal stream ID is 2^62 − 1, which is the index 2^60 − 1 within the server-initiated unidirectional type. Implementations commonly restrict the space further because a sixty-bit index is far more than any connection will use.

Because the type is in the number, a receiver can reject illegal use without any state. A frame arriving for a server-initiated unidirectional stream that carries data from the client is a protocol error regardless of whether the stream was previously seen, and the same holds for a stream ID whose type does not match the sender’s role.

Implicit opening, and what it does to limits

There is no OPEN frame. A stream exists once an endpoint has sent a frame on it, and a receiver must accept any ID below its current limit for that type even if nothing has arrived on it yet. That rule is what allows frames to be reordered freely: a sender may write to streams 0, 4, and 8 in that order, and the packets carrying them may arrive as 8, 0, 4. The receiver cannot treat the gap at 0 and 4 as a protocol violation, because a lower-numbered stream that has not been used yet is indistinguishable from one whose data is still in flight.

The consequence is that the peer must be prepared to allocate state for every stream below the highest it has seen. A receiver that learns of stream 8 has implicitly learned that streams 0 and 4 exist. Implementations handle this by tracking a high-water mark per type and instantiating per-stream state lazily, on the first frame that actually arrives, but the memory commitment is real: an attacker that opens a high-numbered stream forces the receiver to account for all the intermediate ones, which is why the stream limit is the primary defence.

Limits are counted per type, and they are counted in streams rather than bytes. MAX_STREAMS carries a maximum stream count for bidirectional streams and a separate frame type for unidirectional ones. A connection’s initial limits are set by transport parameters during the handshake, and a peer that wants more sends MAX_STREAMS as credit is consumed. The count is of streams that could be open, not of streams that are open: the limit names the largest stream index the peer may create, expressed as a count of streams of that type.

The sending side: ready, send, data sent, data received

A sender’s view of a stream has four progressive states for the normal path. In Ready the application has been told the stream exists but has produced nothing. In Send, STREAM frames are going out. In Data Sent, the final byte and the FIN flag have been transmitted. In Data Recvd, all of it has been acknowledged.

Two terminal states exist off the normal path. If the sender decides to abandon the stream, it sends RESET_STREAM, which carries the stream ID, an application error code, and the final size of the stream, meaning the number of bytes the sender had committed to sending. That final size is not bookkeeping trivia: the receiver must enforce it, because a receiver’s flow-control accounting depends on the total bytes the stream consumed, and a sender that later sends more bytes than it declared has violated the transport.

The fourth field of RESET_STREAM exists because a reset may overtake data in flight. The receiver may have already received some bytes when the reset arrives; those bytes are discarded, but they count against flow control, and the final size in the reset tells the receiver how many bytes to charge to the connection. If a STREAM frame arrives after the reset with an offset beyond the declared final size, that is a FINAL_SIZE_ERROR and the connection is closed. The check must be against the declared size, not against the highest offset seen.

The receiving side: size known, then read

A receiver’s states are Recv, Size Known, Data Recvd, Data Read, plus the reset pair. The transition that matters is the one out of Recv: the receiver learns the final size either from a STREAM frame with the FIN bit set or from RESET_STREAM. Once the size is known, the receiver can detect a truncated stream immediately rather than waiting for a timeout, and it can refuse frames that would extend the stream beyond it.

Receiving a FIN does not mean the application has read the data. That distinction is where the two sides of the state machine diverge, and it is the reason a stream can be “closed” for the transport while still holding memory in the application’s read buffer. QUIC has no separate close handshake for this reason: the sender stops tracking a stream once its data is acknowledged or its reset is acknowledged, and the receiver stops tracking it once the application has consumed the data or the reset has been delivered. Neither side needs to agree on a moment of closure, because both sides can determine independently when they are done.

Stopping a stream that the peer is still sending

RESET_STREAM tells the peer to discard what has been sent. It does not stop the peer from sending more, because the reset travels in one direction. A receiver that no longer wants the data sends STOP_SENDING, carrying the stream ID and an application error code; the sender is then required to respond with RESET_STREAM. This is the mechanism behind an aborted request where the response body is no longer needed: the client stops reading and asks the server to stop writing.

The pair is deliberately asymmetric. STOP_SENDING is a request, and a sender that has already finished sending may ignore it in the sense that its data is already in flight; the required response is a reset, and the receiver must handle receiving data for a stream it has asked to stop. Implementations typically keep a short-lived record for such streams, discarding incoming STREAM frames, until the reset arrives. Dropping the record immediately and treating late data as an error would break against any peer whose reset crosses the stop request on the wire.

Errors that are stream-scoped and errors that are not

QUIC separates the two carefully. A malformed frame, a stream ID used in the wrong direction, a flow-control violation, or a final-size mismatch is a connection error: the whole connection closes with a CONNECTION_CLOSE frame, because the peer’s view of the shared state can no longer be trusted. An application-level failure on one stream, such as a request that cannot be served, is signalled with RESET_STREAM carrying an application error code and leaves the connection intact.

The dividing line is whether the error corrupts state shared by all streams. Getting that boundary wrong is expensive in both directions: closing the connection for a per-request failure throws away every other stream, while continuing after a flow-control violation lets a peer’s accounting drift from the receiver’s until the two disagree about how much credit exists. The frames that carry these signals are described alongside the rest of the wire format in packet and frame layout on the wire, and the credit accounting itself is the subject of connection and stream flow control.

One further asymmetry deserves mention. The stream limit is the only bound on how many streams a peer may create, and it is enforced by the receiver, not negotiated. A peer that exceeds it has committed a connection error, and the receiver has no obligation to be graceful about it: accepting the stream would mean accepting an unbounded allocation decision made by the other side.

References

  1. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF
  2. RFC 9114: HTTP/3 — IETF