QUIC’s flow control is actually a sophisticated dance of two independent limits, one for individual streams and one for the entire connection, that work together to prevent any single stream from hogging all the bandwidth.

Let’s see this in action. Imagine we have a web server using QUIC, serving multiple resources (images, scripts, CSS) over a single connection. Each of these resources can be on its own QUIC stream.

// Client initiates a request for /image.jpg on stream 5
// Server responds with image data on stream 5

// Client initiates a request for /script.js on stream 7
// Server responds with script data on stream 7

If /image.jpg is a massive file and its stream’s flow control window is huge, it could theoretically push out the smaller /script.js if there were only a single, connection-wide limit. QUIC prevents this by having both stream-level and connection-level flow control.

The core problem QUIC flow control solves is managing the finite buffer space on both the sender and receiver. Without it, a sender could overwhelm a receiver’s memory, leading to dropped packets and retransmissions. More subtly, a single "noisy" stream could starve other "well-behaved" streams on the same connection, leading to poor overall application performance. QUIC needs to ensure that bandwidth is shared fairly and that no single data source can monopolize the pipe.

Internally, QUIC uses a credit-based flow control mechanism. When a receiver has buffer space available, it sends a WINDOW_UPDATE frame to the sender. This frame tells the sender how many additional bytes of data it can send for a specific stream or for the entire connection. The sender keeps track of these credits. When it sends data, it decrements the relevant window. When it receives a WINDOW_UPDATE, it increments the window. If the sender tries to send more data than its current window allows, it must pause transmission for that stream or connection until more credits are received.

The two key levers you control are the initial window sizes and the maximum window sizes.

  • Initial Stream-Local Window Size (BIDI_MAX_STREAMS and MAX_OPEN_STREAMS related): This is the starting point for how much data a sender can send on a single bidirectional or unidirectional stream before needing an update. It’s often configured on the server. For example, an initial stream window of 64 KB is common.

    • Configuration (Server - quic.go example):
      listener, err := quic.ListenAddr(addr, tlsConfig, &quic.Config{
          Max estabelecer Streams: 100, // Max bidirectional streams
          Max Unidirectional Streams: 100, // Max unidirectional streams
          Initial StreamSender Window: 65536, // 64KB
      })
      
    • Why it works: This sets the initial "buffer" for any new stream. A larger initial window can reduce the number of WINDOW_UPDATE frames needed for smaller transfers, improving latency. A smaller one conserves memory.
  • Initial Connection-Local Window Size (INITIAL_CONNECTION_WINDOW): This is the total amount of data all streams on a connection can collectively send before the connection-level window needs an update. It’s also typically set on the server. A common value is 2 MB.

    • Configuration (Server - quic.go example):
      listener, err := quic.ListenAddr(addr, tlsConfig, &quic.Config{
          // ... other config ...
          Initial Connection Sender Window: 2097152, // 2MB
      })
      
    • Why it works: This acts as an overall throttle. Even if individual streams have large windows, the connection window prevents the aggregate data from overwhelming the receiver’s total buffer capacity.
  • Maximum Stream-Local Window Size (MAX_STREAM_DATA): This is the absolute maximum a single stream’s window can grow to. It’s a safeguard against runaway growth.

    • Configuration (Server - quic.go example):
      listener, err := quic.ListenAddr(addr, tlsConfig, &quic.Config{
          // ... other config ...
          Max Stream Sender Window: 6291456, // 6MB (example)
      })
      
    • Why it works: This prevents a single misbehaving or very large stream from consuming an unbounded amount of receiver memory, even if the receiver keeps sending WINDOW_UPDATE frames.
  • Maximum Connection-Local Window Size (MAX_CONNECTION_DATA): Similar to the stream maximum, this is the ceiling for the connection-level window.

    • Configuration (Server - quic.go example):
      listener, err := quic.ListenAddr(addr, tlsConfig, &quic.Config{
          // ... other config ...
          Max Connection Sender Window: 15728640, // 15MB (example)
      })
      
    • Why it works: This limits the total memory usage for all streams on the connection, ensuring the overall connection doesn’t consume excessive resources.

The crucial point is that data can only be sent if both the stream-level window and the connection-level window have sufficient credits. If a stream is sending 100 KB of data, and its stream window has 50 KB available and the connection window has 200 KB available, it can only send 50 KB. The sender must wait for both windows to advance.

Most QUIC implementations will automatically grow the windows up to their maximums as data is sent and acknowledged. The WINDOW_UPDATE frames are sent proactively by the receiver when it frees up buffer space. The size of these updates is often dynamic, meaning the receiver might send an update for 10 KB if it just freed up 10 KB, or it might accumulate and send a larger update for 64 KB when it has freed up that much space. This dynamic adjustment is key to efficient bandwidth utilization.

What people often miss is that the WINDOW_UPDATE frames themselves consume bandwidth and introduce latency. Sending too many small updates can be inefficient. Conversely, waiting too long to send an update can lead to stalls. The optimal strategy depends on the network conditions and the application’s data patterns. Some implementations might have internal logic to decide when to send WINDOW_UPDATE frames, perhaps based on a percentage of the current window or a fixed byte count.

The next common problem you’ll encounter is managing the congestion control aspect, which works in tandem with flow control to ensure packets are actually delivered reliably over a lossy network.

Want structured learning?

Take the full Quic course →