Internet Protocols · QUIC and HTTP/3

Two-Level Flow Control and Why Credit Must Be Advertised

How stream and connection limits interact, why a receiver cannot simply stop reading, and what happens to credit when a stream is reset.

QUIC limits a sender twice: once for each stream and once for the connection as a whole. The two limits are not redundant. The stream limit stops one stream from consuming the entire connection’s buffer, and the connection limit stops a peer from multiplying its way past the per-stream limit by opening many streams. A sender may send on a stream only while both its stream credit and the connection credit are positive, and each byte it sends consumes one unit from each.

Credit is a byte count, not a window

The unit is bytes of stream data, counted from zero at the start of each stream. A receiver advertises a maximum offset it is willing to accept, per stream and per connection, using MAX_STREAM_DATA and MAX_DATA respectively. A sender that has sent N bytes on a stream may send more only while N is below the stream’s advertised maximum and the total across all streams is below the connection’s maximum.

The values are absolute, not incremental. A MAX_DATA frame carries the new total maximum, and a receiver that sends a value lower than one it has already sent has committed a protocol error. This makes the frames idempotent and makes reordering harmless: an old MAX_DATA frame that arrives after a newer one is simply ignored, because the receiver of the frame keeps the larger value. It also means a lost MAX_DATA frame needs no retransmission of its own, since the next one supersedes it.

The initial values come from transport parameters during the handshake, so no credit needs to be granted before the first byte. A peer that has not yet received any MAX_* frame can send up to the initial limit, and the receiver must be prepared to buffer that much per stream and in aggregate. The initial connection limit is the product that matters: a peer may open as many streams as the stream limit allows, and each may send up to the stream limit, so the receiver’s worst-case commitment is the product of the two limits unless the connection limit is smaller.

Why a receiver must keep advertising

A receiver that stops reading its socket cannot simply stop granting credit, because the sender will eventually run out and the connection will stall. That is the intended behaviour for backpressure: a slow application should slow the sender. But a receiver that never grants more credit while the application is not reading is indistinguishable from a receiver that has died, and the sender’s loss detection will eventually declare the connection failed.

The practical design is an autotuning window. The receiver grants credit as the application consumes data, keeping the advertised maximum ahead of the consumed offset by a margin. The margin determines how much data can be in flight without the application’s involvement: too small and the sender stalls waiting for an update; too large and the receiver buffers data it may never need. A common approach is to grow the window while the application keeps up and to stop growing it, or reduce it, when it does not, using the observed rate of consumption rather than a fixed constant.

The cost of a small window is round trips. A sender that exhausts its credit must wait for a MAX_STREAM_DATA frame to arrive, which takes at least one round trip, and the transfer stalls for that period. The cost of a large window is memory: the receiver must be prepared to hold the full advertised amount, because it cannot refuse data that arrives within the credit it granted. The right size is therefore a function of the bandwidth-delay product of the path, which the receiver does not know and must estimate.

Reading the socket is not the same as consuming

An implementation that decrypts packets and queues stream data in memory has consumed nothing from the application’s perspective. The credit should be released when the application reads, not when the bytes arrive, because the memory is still held. Implementations that release credit at arrival turn flow control into a lie: they advertise a window they are not actually willing to buffer, and a peer that fills it causes the receiver to drop data, which the transport then has to retransmit.

The distinction also affects what happens when the application is slow but not stopped. A receiver that reads in large chunks releases credit in bursts, and the sender sees the window jump. A receiver that reads byte by byte releases credit continuously, and the sender sees a steady trickle. Both are correct, but the burst pattern interacts with the congestion controller, which may interpret a sudden window increase as a signal to send more.

Resets consume credit permanently

A stream that is reset still consumes connection-level credit for the bytes the sender had committed to send, even if the receiver discarded them. This is the purpose of the final size in RESET_STREAM: it tells the receiver how many bytes to charge to the connection, so that the sender cannot reset a stream and recover the credit it had already spent.

The rule prevents a straightforward abuse. Without it, a sender could send a large amount of data on a stream, reset the stream, and then send the same amount again on another stream, having never paid for the first. With it, the credit is spent whether or not the data was delivered, and the connection limit remains an accurate bound on the total the sender has committed.

A receiver that has received RESET_STREAM must also handle data that arrives afterward. Bytes within the declared final size are discarded but counted; bytes beyond it are a connection error, because the sender has contradicted its own declaration. The check must be against the declared final size rather than against the highest offset observed, since the sender may have sent data that arrived before the reset and never declared it.

STOP_SENDING has a similar accounting consequence from the other direction. A receiver that sends STOP_SENDING expects a RESET_STREAM in response, and until that arrives it may still receive data. The credit for that data is consumed when it arrives, regardless of the fact that the receiver has asked for it to stop, because the sender has already spent its budget to put it on the wire.

The connection limit is the real bound on memory

Per-stream limits are easy to reason about, but the connection limit is what protects the receiver’s memory. A peer that opens a thousand streams, each with a modest per-stream window, can still commit a large aggregate. The connection limit caps that aggregate directly, and a receiver that wants to bound its buffer must set it to the amount it is willing to hold across all streams.

There is an interaction with the stream limit that is easy to miss. A receiver that advertises a large stream limit and a small connection limit gives its peer a choice: open many streams and have them share a small aggregate, or open few and let each use more. The former is better for latency because a stalled stream does not block others, but it means each stream’s effective window is the connection limit divided by the number of active streams. A receiver that advertises a large stream limit and a small connection limit is implicitly telling the peer that multiplexing is cheap but bandwidth is not.

The reverse configuration, a small stream limit and a large connection limit, makes each stream comfortable but limits concurrency. HTTP/3’s request-per-stream model means the stream limit is also a limit on concurrent requests, so a server that sets it too low will serialize work that could proceed in parallel. This is one of the places where transport parameters are an application-level policy decision rather than a transport one.

Credit updates are cheap and should be frequent

A MAX_STREAM_DATA frame carries a stream ID and a varint maximum, so it costs a few bytes. Sending one per read is affordable, and the alternative — batching updates — adds latency to the sender’s next send. The specification allows a receiver to send an update whenever the new maximum exceeds the old by any amount, and it recommends sending one when the window has been substantially consumed, so that the sender never runs dry.

The failure mode of infrequent updates is a connection that runs at the speed of its acknowledgements rather than the speed of the path. Each time the sender exhausts credit it must wait a round trip, and if the window is only slightly larger than the bandwidth-delay product, the transfer alternates between bursts and stalls. A window at twice the bandwidth-delay product avoids this in steady state, at the cost of buffering that much data.

The connection-level frame, MAX_DATA, has the same structure with a single maximum. Both frames are carried in the encrypted payload described in packet and frame layout on the wire, and both are subject to the same varint encoding. Because it is shared, a receiver must be careful about how it updates it: granting credit for one stream’s consumption raises the aggregate maximum for all streams, and a peer can then spend that credit on a different stream. That is intended, since the aggregate is what the receiver is buffering, but it means the connection maximum must be advanced based on total bytes consumed rather than per stream.

Both frames are subject to the same monotonicity rule, and both are ignored if they carry a value below the current maximum. The varint encoding means a maximum can be updated in place without changing the frame size for values in the same range, which keeps the overhead predictable as a transfer grows. The stream-level accounting is described alongside the rest of the stream lifecycle in stream identifiers and the closing state machine.

References

  1. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF
  2. RFC 9114: HTTP/3 — IETF
  3. RFC 1122: Requirements for Internet Hosts — Communication Layers — IETF