Down the QUIC Rabbit Hole, It All Started With MAX_STREAMS

How a single line of code led to understanding QUIC's revolutionary approach to transport, from TCP's head-of-line blocking to independent streams and flow control.

5 min read
networkingquichttp3tcp

A few nights ago, while poking around a QUIC client implementation, I stumbled upon a curious bit of code in Cloudflare's quiche repository:

// In quiche examples/client.rs
conn.set_max_streams_bidi(100)?;
conn.set_max_streams_uni(3)?;

At first glance, it looked harmless, just setting how many streams a client can open. But that line sent me spiraling down a rabbit hole about how QUIC manages data, concurrency, and flow control, and why it's such a fundamental leap beyond TCP.

What You'll Learn:

  • How TCP's head-of-line blocking creates bottlenecks
  • Why QUIC's independent streams solve this problem
  • What MAX_STREAMS does and why it matters
  • How stream IDs work and prevent collisions
  • QUIC's two-layer flow control system

The TCP Legacy: A Single-Lane Highway

For decades, the Internet relied on TCP, a protocol that guarantees:

  • Reliability — every byte arrives
  • In-order delivery — bytes arrive exactly in sequence

That second feature, in-order delivery, sounds like a good thing… until it's not. Because if one packet goes missing, everything behind it must wait.

The Problem: Head-of-Line (HOL) Blocking

This is the networking equivalent of a traffic jam behind one stalled car. One lost packet blocks everything behind it, even if those packets arrived successfully.

What's Happening Under the Hood

When packets arrive at the receiver, TCP uses a receive buffer to temporarily store them until they can be passed up to the application in the correct order.

Each packet has a sequence number:

TCP Flow Diagram

Here's what happens next:

TCP's Sequential Delivery Process:

  1. Packets arrive out of order from the network. In this example, Packet2 arrives before Packet1.
  2. The buffer receives both packets and holds them temporarily.
  3. The buffer checks the order for Packet1. Since Packet1 is the next expected packet, the order is correct.
  4. The buffer sends Packet1 to the browser.
  5. The buffer checks the order for Packet2. Since Packet1 has been delivered, Packet2 is now the next expected packet, so the order is correct.
  6. The buffer sends Packet2 to the browser.
  7. The browser processes the packets in the correct order (Packet1, then Packet2).

This enforced order is why TCP stalls. It's not lazy, it's loyal to sequence integrity.

Why It Gets Worse with HTTP/2

HTTP/2 multiplexes multiple resources (HTML, CSS, JS, images) over one TCP connection. So if one TCP segment is missing, every HTTP stream waits for that retransmission.

The HTTP/2 Bottleneck

Even if your image data (packets #3–#5) is fine, it won't reach the browser until the lost CSS packet (#2) is back. That's how one tiny packet drop can ripple across an entire webpage.


QUIC's Elegant Fix: A Multi-Lane Highway

QUIC, built on UDP, reimagines transport. Instead of one global stream, it introduces multiple independent streams, each with its own byte order, reliability, and flow control.

Think of it like multi-lane traffic: if one lane (stream) stalls, the others keep flowing.

HTTP/3 maps each HTTP request/response pair to a separate QUIC stream. So if /style.css gets delayed, /index.html and /image.jpg keep downloading smoothly.

Key Benefits:

  • No head-of-line blocking between different resources
  • Each stream has independent ordering and reliability
  • Lost packets only affect their specific stream

QUIC Independent Streams


Enter MAX_STREAMS: The Concurrency Gatekeeper

Back to that code I found:

conn.set_max_streams_bidi(100)?;
conn.set_max_streams_uni(3)?;

These lines define how many concurrent streams the peer can open at once. It's controlled via a frame called MAX_STREAMS, which is part of QUIC's flow control system.

In essence: "You can open up to N streams right now. Once some close, I'll let you open more."

This prevents one side from flooding the other with thousands of streams and exhausting memory.

For example, a server might advertise:

  • MAX_STREAMS (bidirectional) = 100
  • MAX_STREAMS (unidirectional) = 3

What This Means:

The client can open:

  • 100 bidirectional streams (request/response pairs)
  • 3 unidirectional streams (control messages)

...all at the same time!


Stream IDs: How QUIC Identifies Streams

QUIC uses 62-bit stream IDs, encoding both the initiator and stream type:

Initiator Type Stream IDs
Client Bidirectional 0, 4, 8, 12, …
Server Bidirectional 1, 5, 9, 13, …
Client Unidirectional 2, 6, 10, 14, …
Server Unidirectional 3, 7, 11, 15, …

Why This Design?

These IDs make stream ownership clear and ensure no collisions. The last two bits encode who created the stream and what type it is.


Streams in Action: Real-World Multiplexing

When your browser fetches multiple assets, HTTP/3 simply opens new streams:

Resource Stream ID (example)
/index.html 0
/style.css 4
/image.jpg 8

Each stream carries one request/response pair. When packets arrive, QUIC knows exactly which stream (and thus which resource) they belong to.

No guessing, no blocking.


Flow Control: Two Layers of Protection

QUIC applies flow control in two scopes:

Two-Layer Flow Control:

  1. Per-stream — limits data in each individual stream buffer
  2. Per-connection — caps total buffered data across all streams

Together, they prevent any single stream from dominating the connection.


The "Aha" Moment: Why This Matters

That one config line…

conn.set_max_streams_bidi(100)?;

…isn't just a parameter. It represents a boundary of concurrency, the handshake between safety and speed.

The Fundamental Shift:

TCP QUIC
Forces order globally Isolates ordering per stream
Blocks everyone for one lost packet Lets other streams flow freely
Single-lane highway Multi-lane highway