§7.2
Chapter 07 · Transport Parameterization and Negotiation

§7.2Limits for Streams and Flow Control with Practical Selection Guidance

RFC 9000

Learning objective. Understand QUIC's two-level flow control and the stream-count limits, untangle the three initial_max_stream_data_* variants, and learn to size these limits from a connection's bandwidth-delay product rather than by guesswork.

Two levels of flow control #

QUIC limits data at two granularities at once ([RFC 9000 §4.1]):

  • Stream flow control keeps one stream from consuming the whole connection's receive buffer.
  • Connection flow control caps the sum of stream data across all streams, protecting the connection's total buffer.

A sender "MUST NOT send data in excess of either limit" ([RFC 9000 §4.1]). The receiver sets the initial values in transport parameters, then raises them at runtime: MAX_STREAM_DATA lifts one stream's ceiling, MAX_DATA lifts the connection-wide ceiling ([RFC 9000 §19.9–19.10]). When a sender hits a ceiling it is blocked and should say so with a STREAM_DATA_BLOCKED or DATA_BLOCKED frame; violating an advertised limit is a FLOW_CONTROL_ERROR (0x03).

Receiver (sets thelimits)Senderhandshake:initial_max_data=64-K ·initial_max_stream_-data_bidi_remote=16-K ·initial_max_streams-_bidi=3stream 0 hit its 16Kstream limitstream 0 may nowsend to 48Kconnection-wide cap: sum over allstreams ≤ initial_max_data, raised byMAX_DATAopens stream 0, sends 16 KBSTREAM_DATA_BLOCKED(stream 0, limit 16K)MAX_STREAM_DATA (stream 0,48K)opens streams 4, 8 — threeconcurrent, at the limitSTREAMS_BLOCKED (bidi, limit3)MAX_STREAMS (bidi, 100)DATA_BLOCKED (limit 64K)MAX_DATA (256K)
Fig. 7.2-1Both flow-control levels and the stream-count limit in action. A stream blocks at its per-stream ceiling and is raised by MAX_STREAM_DATA; the third concurrent stream blocks at the count limit and is raised by MAX_STREAMS; and the connection-wide sum blocks at initial_max_data and is raised by MAX_DATA. The receiver — which declared the limits — is the one that raises them.RFC 9000 §4.1, §4.6

The three per-stream limits, untangled #

The confusing part is that there are three per-stream flow-control parameters, and which one governs a given stream depends on who opened it and its direction ([RFC 9000 §18.2]):

Parameter Governs streams… Opened by
initial_max_stream_data_bidi_local (0x05) bidirectional the sender of the parameter
initial_max_stream_data_bidi_remote (0x06) bidirectional the peer (receiver of the parameter)
initial_max_stream_data_uni (0x07) unidirectional the peer (receiver of the parameter)

The mnemonic that untangles it: _local governs streams you open; _remote/_uni govern streams the peer opens toward you. So a server's initial_max_stream_data_bidi_remote is the limit a client's request stream starts with. It is the number that decides how many request-body bytes a client can send before waiting for a MAX_STREAM_DATA. Get this one wrong and client uploads stall mysteriously while downloads are fine. If a parameter is absent, streams of that type start with a limit of 0 ([RFC 9000 §18.2]): they can be opened but not written to until a MAX_STREAM_DATA arrives.

Limiting how many streams exist #

Separately from how much each stream may carry, initial_max_streams_bidi (0x08) and initial_max_streams_uni (0x09) cap how many streams the peer may open ([RFC 9000 §4.6]). The peer raises the cap with MAX_STREAMS and signals starvation with STREAMS_BLOCKED. If the parameter is absent or zero, the peer cannot open streams of that type until a MAX_STREAMS frame grants some. Opening a stream past the limit is a STREAM_LIMIT_ERROR (0x04), and a limit above 2^60 is itself an error ([RFC 9000 §4.6]).

For HTTP/3 this is the concurrency knob: every request is one client-initiated bidirectional stream (§5.3), so initial_max_streams_bidi is the maximum number of in-flight requests the server will allow — the QUIC analogue of HTTP/2's SETTINGS_MAX_CONCURRENT_STREAMS.

Choosing the values: size to the BDP #

The limits exist to bound memory, but set them too low and throughput suffers. The governing fact:

"If an endpoint cannot ensure that its peer always has available flow control credit that is greater than the peer's bandwidth-delay product on this connection, its receive throughput will be limited by flow control." ([RFC 9000 §4.3])

So the floor for initial_max_data (and for a bulk stream's initial_max_stream_data) is the bandwidth-delay product: a 100 Mbps path at 50 ms RTT has a BDP of about 625 KB, so a connection-level limit below that throttles throughput no matter how good congestion control is. Two practical rules follow:

  • Raise credit before the sender blocks. A receiver "MUST NOT wait for a STREAM_DATA_BLOCKED or DATA_BLOCKED frame before sending" more credit ([RFC 9000 §4.2]); waiting guarantees a stall of at least one RTT. Autotuning (growing the advertised window from an RTT estimate and the rate the application actually drains data, like modern TCP) is the recommended approach ([RFC 9000 §4.2]).
  • Piggyback the updates. Sending MAX_DATA/MAX_STREAM_DATA alongside ACKs "reduces the cost of those updates" ([RFC 9000 §4.3]).

Worked example: sizing a request/response server #

Take an HTTP/3 server for an API with small requests and larger responses, on paths around 50 ms RTT and 50 Mbps (BDP ≈ 300 KB). Reasonable server declarations: initial_max_streams_bidi = 100 (plenty of concurrent requests); initial_max_stream_data_bidi_remote = 256 KB (each client request stream can send a sizeable body before needing more credit; this is the client's upload limit); initial_max_stream_data_bidi_local = 256 KB (governs streams the server opens, rare in plain request/response); and initial_max_data ≈ 1 MB, above the BDP and large enough to let several streams progress at once. The client's mirror-image choices govern the server's responses. Then let autotuning grow the windows for the connections that turn out to be high-BDP, rather than provisioning every connection for the worst case up front.

Note

Flow control and idle timeout interact. A sender that is flow-control blocked with nothing ack-eliciting in flight should periodically send a STREAM_DATA_BLOCKED/DATA_BLOCKED frame, or the connection can hit the idle timeout while "stuck" and be closed ([RFC 9000 §4.1], and §7.3). Blocked is a state you announce, not one you sit in silently.

In practice

editorial Default library settings are tuned for general web traffic and are often too small for bulk transfer and occasionally too large for a memory- constrained server fronting millions of connections. Size deliberately: for throughput, put initial_max_data and the relevant initial_max_stream_data_* comfortably above the path BDP and enable autotuning; for a server guarding memory, cap initial_max_streams_* and the per-stream limits so that worst-case buffering (streams × per-stream limit, plus the connection limit) times your connection count fits in RAM. The failure signatures are distinct — throughput plateaus well under the congestion window means flow control; uploads stalling while downloads fly means you sized bidi_remote wrong.

Takeaways #

QUIC flow control works at two levels (per stream and per connection), each initialized by a transport parameter and raised at runtime by MAX_STREAM_DATA / MAX_DATA, with stream counts capped separately by initial_max_streams_* and raised by MAX_STREAMS. The three initial_max_stream_data_* variants split by who opened the stream: _local for yours, _remote/_uni for the peer's. Size all of them from the path's bandwidth-delay product and let autotuning adapt, raising credit before the sender blocks. The one limit that is a time rather than a volume, max_idle_timeout, is §7.3.