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).
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_BLOCKEDorDATA_BLOCKEDframe 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_DATAalongside 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.
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.
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.