chaowen.← Notes

Networking / HTTP/3 protocol deep dive

2026-09-28

QPACK blocked streams: the compression trade-off hidden inside HTTP/3

HTTP/3 runs independent request streams over QUIC, so packet loss on one stream need not stall every other request. But compressing headers across requests introduces a shared dependency: an encoder may reference a dynamic-table entry the decoder has not received yet. QPACK solves this with explicit acknowledgments and a negotiated limit on how many streams may wait.

1. The concrete problem

Web requests repeat fields. A browser may send the same :authority, user-agent, cookie names, and cache directives on dozens of requests. Sending each field as literal bytes wastes bandwidth. Header compression replaces repeated name/value pairs with compact references into a table shared by encoder and decoder.

In HTTP/2, HPACK has one ordered byte stream for a connection. Its decoder can process table updates before later header blocks. HTTP/3 maps requests onto independent QUIC streams. QUIC may deliver stream data in different orders, and different streams progress independently. If request stream A refers to a new table entry carried on the encoder stream, but that instruction has not arrived, A cannot be decoded yet.

The design tension: wait for the missing table instruction (compression efficiency), or encode without depending on it (more bytes, less waiting). QPACK makes that choice explicit and bounds the waiting.

2. Static table, dynamic table, and three streams

QPACK, specified by RFC 9204, keeps a static table of common field entries and a connection-specific dynamic table of recently inserted entries. Static references are immediately known to both peers. Dynamic entries are created by encoder instructions and can be reused until evicted.

HTTP/3 reserves unidirectional streams for QPACK control: one encoder stream carries table changes from request sender to receiver; one decoder stream carries acknowledgments and cancellation signals back. Request streams carry header blocks. Those are distinct byte streams, so stream scheduling and delivery order matter.

HTTP/3 sender (encoder) receiver (decoder) request stream 7: HEADERS -- ref entry 62 ---------------------> QPACK encoder stream: INSERT entry 62 --------------------------> QPACK decoder stream: Section Ack <----- If HEADERS arrives before INSERT: stream 7 may be QPACK-blocked. Other request streams can still progress, subject to connection resources.

An entry is not safe to reference merely because the encoder wrote an insertion instruction to its local output buffer. The encoder tracks which insertions the decoder is known to have processed. The decoder tracks the received insert count. A header block declares its Required Insert Count (RIC): the minimum dynamic-table insert count needed to decode it. If received count is below RIC, that stream is blocked until the encoder stream advances the table.

3. How an entry becomes usable

  1. Insert. The encoder emits an instruction such as “insert literal name/value” on its QPACK encoder stream. The dynamic-table insertion count advances.
  2. Reference. A later header block on a request stream encodes a dynamic reference and an encoded Required Insert Count. The RIC communicates which table state is required.
  3. Wait or decode. If the decoder has processed enough encoder instructions, it resolves the reference. Otherwise it blocks that request stream; it must not guess what the index means.
  4. Acknowledge. After decoding a header section that used dynamic entries, the decoder sends a Section Acknowledgment naming the request stream. This tells the encoder the section was processed and can release references/resources subject to the table rules.
  5. Evict safely. The encoder may evict old entries only when doing so cannot invalidate an outstanding reference. Decoder stream feedback and known received counts constrain safe eviction.

RIC is encoded modulo a value derived from the maximum table capacity, rather than sent as an unbounded absolute integer. The decoder reconstructs the absolute count near its current insert count. This is compact, but requires careful arithmetic and validation; the RFC defines the reconstruction procedure and error conditions.

Acknowledgment is also why QPACK is not simply “HPACK over QUIC.” HPACK can rely on a single ordered connection stream. QPACK allows independent request streams, but must communicate dependencies and constrain use of entries whose creation may be delayed.

4. Blocking limits: bounded coupling, not zero coupling

The decoder advertises SETTINGS_QPACK_BLOCKED_STREAMS, the maximum number of request streams it is willing to have blocked on dynamic-table state. The encoder must not create more potentially blocked streams than that limit. A value of zero tells the encoder not to use dynamic references that could block; it can still use static entries and literals. A higher value permits stronger compression but accepts more waiting state.

This limit controls a specific protocol condition: streams that could be blocked by missing insertions. It is not a complete memory limit, request concurrency limit, or defense against every resource-exhaustion attack. Implementations still need limits for header-list size, table capacity, stream count, CPU, and connection-level buffering.

The performance trade-off is workload dependent. Repeated large cookies and stable headers can make dynamic reuse valuable. A small table, low blocked-stream limit, or high encoder-stream latency pushes the encoder toward literals and reduces compression. A large blocked limit can improve byte efficiency but makes latency more sensitive to control-stream scheduling and delays more stream state behind the insert dependency.

Head-of-line blocking nuance

QUIC removes TCP-level cross-stream head-of-line blocking: loss of bytes on one QUIC stream does not inherently stop delivery of bytes on every other stream. QPACK can still introduce application-level dependency blocking on a particular request stream. The distinction matters: one missing QPACK instruction can hold requests that reference it, but it does not impose a universal connection-wide stall. A connection can nevertheless suffer broader impact if the encoder stream is starved, many requests depend on its updates, or implementation resource pressure couples streams.

5. Failure modes and debugging

Useful observability includes blocked-stream count and duration, encoder/decoder stream bytes, insertion count, table occupancy, entry evictions, acknowledgment lag, compression ratio, and request latency split by whether a header block referenced dynamic state. Avoid logging sensitive header values such as cookies or authorization tokens.

6. Reproducible lab

Goal: see how an advertised blocking limit changes whether a peer may rely on a not-yet-received dynamic entry. This conceptual configuration lab uses curl and an HTTP/3 endpoint for negotiation visibility; packet captures or a protocol test harness are needed to inspect QPACK instructions. Prerequisites: curl built with HTTP/3 support and a reachable HTTP/3 origin.

# Confirm this curl build has HTTP/3
curl --version | grep -i HTTP3

# Request HTTP/3; inspect negotiated protocol and response timing
curl --http3-only -sS -o /dev/null \
  -w 'http=%{http_version} total=%{time_total}\n' \
  https://cloudflare-quic.com/

# Compare repeated requests; the second request may reuse connection state
curl --http3-only -sS -o /dev/null -w '%{size_download} %{time_total}\n' \
  https://cloudflare-quic.com/
curl --http3-only -sS -o /dev/null -w '%{size_download} %{time_total}\n' \
  https://cloudflare-quic.com/

Expected: an HTTP/3-capable build reports protocol version 3. These commands do not guarantee dynamic-table reuse: connection reuse, server configuration, response headers, and curl's HTTP/3 backend determine behavior. If curl says HTTP/3 is unsupported, install a build with an HTTP/3 backend or use the fallback below rather than claiming the experiment ran.

Reveal the dependency: in a controlled HTTP/3 client/server test harness, set decoder blocked-stream limit first to zero, then to a small positive value. Send repeated header fields and deliberately delay the server's QPACK encoder-stream delivery while delivering request HEADERS promptly. At zero, the encoder must avoid references that could block; at a positive limit, eligible blocks may reference new entries and stall until insertion arrives. Record compressed bytes, blocked duration, and number of blocked streams.

Fallback / safety: public endpoint behavior is uncontrolled and can change. For deterministic experiments use the quiche or nghttp3 test suites and their local client/server examples; instrument the QPACK decoder's blocked-stream path. Do not inject delay or malformed frames into third-party services.

7. Production and security

QPACK reduces redundant metadata without requiring one ordered compression context for all request streams. That independence comes with state, feedback, and protocol error cases. In production, tune table capacity and blocked-stream settings using actual header distributions and tail-latency objectives; do not maximize compression ratio in isolation.

Header compression also has confidentiality implications. Dynamic state is shared across requests on a connection, so implementations must consider whether attacker-controlled and secret-bearing headers can interact in ways that reveal information through compressed sizes or timing. Prefer safe connection boundaries between trust domains, avoid mixing secrets with attacker-controlled reflection where possible, and follow the QPACK/HTTP guidance and implementation-specific mitigations. Never assume encryption hides lengths: TLS or QUIC encrypts content, not every observable size/timing signal.

For diagnosis, capture protocol metadata in a controlled environment, not raw credentials. A good incident report can state that streams were blocked at RIC N while received insert count was M, identify encoder-stream delivery delay, and quantify the affected requests—without recording a single cookie value.

Takeaway

HTTP/3's independent QUIC streams solve transport-level cross-stream stalls, but shared header compression creates explicit dependencies. QPACK makes those dependencies safe with Required Insert Count, an encoder control stream, decoder acknowledgments, and a peer-advertised cap on potentially blocked streams. The practical knob is not “compression versus no compression”; it is a balance among bytes, control-stream delay, per-stream waiting, and retained state.

Primary sources

  1. RFC 9204 — QPACK: Field Compression for HTTP/3 (normative format, Required Insert Count, streams, SETTINGS, errors).
  2. RFC 9114 — HTTP/3 (HTTP mapping and control streams).
  3. RFC 9000 — QUIC: A Transport Layer Network Protocol (independent stream delivery and transport behavior).
  4. Cloudflare quiche source and tests (open-source QUIC/HTTP/3 implementation).
  5. nghttp3 source and tests (HTTP/3 and QPACK implementation).
  6. Cloudflare: HTTP/3, the past, present, and future (historical motivation for replacing HPACK's connection-level ordering assumptions).