Learning objective. Explain QUIC's two-level flow control, how a receiver advances the limits, and the single relationship that decides whether flow control caps your throughput: window versus bandwidth-delay product.
Two windows: per stream and per connection #
Flow control lets a receiver bound how much unread data a sender can push at it, so a slow or busy application is not overrun. QUIC applies it at two levels simultaneously ([RFC 9000 §4]):
- Stream level.
MAX_STREAM_DATAgives the maximum absolute byte offset the sender may reach on one stream. Each stream has its own limit. - Connection level.
MAX_DATAcaps the sum of bytes sent across all streams. This bounds total buffering regardless of how many streams are open.
A sender MUST NOT exceed either limit ([RFC 9000 §4.1]); it is held to whichever binds
first. When it runs into a limit it announces the stall (STREAM_DATA_BLOCKED for a stream
limit, DATA_BLOCKED for the connection limit), which is both a diagnostic and a prompt for
the receiver to extend credit.
The receiver drives the loop: as its application reads buffered data, it advances the
limit by sending MAX_STREAM_DATA / MAX_DATA frames with a higher value (a smaller value
is ignored). Sending these early and often (before the sender actually hits the wall)
is what keeps data flowing; the spec explicitly notes that timely limit updates improve
throughput ([RFC 9000 §4.2]).
The credit itself is tiny on the wire, a type byte and one or two varints
([RFC 9000 §19.9–§19.11], constants.md §4):
The relationship that governs throughput #
Here is the rule worth memorizing: a stream's sustained throughput is at most its
flow-control window divided by the round-trip time. A sender fills the window, then must
wait roughly one RTT for the MAX_STREAM_DATA update to arrive before it can send more. If
the window is smaller than the bandwidth-delay product (BDP = bandwidth × RTT), the pipe
drains during that wait and throughput is capped well below the link's capacity — no matter
how much bandwidth is available.
Concretely, a 100 KB window on a 100 ms path caps a stream at 100 KB / 0.1 s = 1 MB/s ≈ 8 Mb/s, even on a gigabit link. To fill a 100 Mb/s × 100 ms path you need a window of at least ~1.25 MB. This is the classic receive-window problem, and it is why serious QUIC stacks auto-tune the window (growing it based on the observed consumption rate and RTT) rather than leaving it at a small default. Long-fat networks are the subject of Chapter 8; the lever there is almost always this window.
The connection-level MAX_DATA window is shared across every stream, which creates a
subtle coupling: a stream whose application has stopped reading still occupies its buffered
bytes against the connection limit, shrinking the credit available to other streams. So a
single stalled reader can throttle unrelated downloads on the same connection: a
flow-control cousin of head-of-line blocking, distinct from the transport kind QUIC
otherwise removes (§4.4).
editorial When a transfer plateaus below the available
bandwidth, check the flow-control window before the congestion window. A capture that shows
frequent STREAM_DATA_BLOCKED / DATA_BLOCKED frames, or a sender that idles for ~1 RTT
between bursts, is flow-control-limited: the receiver is not advancing limits fast enough for
the BDP. Raise initial_max_data and initial_max_stream_data_* (§7.2) and
enable window auto-tuning. Conversely, do not set the initial windows absurdly high on a
memory-constrained server: the connection window is a commitment to buffer that much
per connection.
Takeaways #
QUIC flow control is two windows — per stream and per connection — that a receiver advances
as its application consumes data, with *_BLOCKED frames signalling stalls. The window must
cover the bandwidth-delay product or it, not the network, sets the ceiling; and the shared
connection window couples streams together. §4.3 turns to the opposite question:
when many streams can send, which should go first?