Skip to content

http3: encode response headers against the QPACK static table - #187

Merged
MDA2AV merged 2 commits into
mainfrom
feat/h3-qpack-static-table
Aug 14, 2026
Merged

http3: encode response headers against the QPACK static table#187
MDA2AV merged 2 commits into
mainfrom
feat/h3-qpack-static-table

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What

Qpack.EncodeResponseFields consulted the static table for :status alone. Every other field went out as Literal With Literal Name, spelling out a name the peer already holds by index — content-type cost 12 bytes on the wire on every response, forever.

Encoding is now three tiers, cheapest first:

case encoding cost
name and value in the table Indexed Field Line (0xC0) 1 byte
name in the table Literal With Name Reference (0x50) index + literal value
neither Literal With Literal Name (0x20) unchanged

Names match case-insensitively (so a caller holding conventional capitalisation resolves, and the name is never written); values match exactly, since a field value is case-sensitive.

The lookup has to be cheap, or it costs more than it saves

Worth calling out, because the first version was a regression. A plain linear scan over the ~50 distinct static names, per header, cost 6% of throughput — more than the bytes it saved. Bucketing by name length narrows it to one or two comparisons, and the saving flips to a gain:

run 1 run 2 run 3
literal names (before) 3,087,560 3,123,721 3,171,774
static table, linear scan 2,771,482 2,941,583 2,993,596
static table, bucketed 3,291,610 3,316,463 3,367,788

h3x -t 8 --connections 8 -m 100, buffered playground. Median 3,123,721 → 3,316,463 req/s, +6%.

One thing left alone deliberately

Http3Response.Text sends content-type: text/plain; charset=utf-8 — with a space. Static entry 54 is text/plain;charset=utf-8 without one, so it misses the one-byte indexed encoding and takes the name reference instead. Dropping the space would make it an exact hit, but that changes what goes on the wire, so it is not in this PR.

Tests

Unit 31 / E2E 51 / Chaos 47, all passing. Notably http3: pure-C# parser GET vs nghttp3 client still passes, which is the check that matters — our encoder's output decoded by an independent QPACK implementation.

New unit tests reach Qpack via InternalsVisibleTo rather than by widening the public surface. They verify by decoding what the encoder produces: an index that is off by one still yields a well-formed field section, so only a round trip catches it naming the wrong header. Covered: exact matches, known-name-unknown-value, capitalised names, unknown names lowercased on the way out, and case-sensitive value matching.

Not addressed

ioxide.http3 has no QPACK dynamic table at all — SETTINGS advertise capacity 0 and blocked streams 0, and the peer's SETTINGS values are walked for framing and discarded. That is unchanged here. Measured across clients, only Chrome advertises any capacity and nothing inserts inbound, so a dynamic table would be a browser-only win; this change benefits every client instead.

MDA2AV added 2 commits August 14, 2026 15:49
The encoder consulted the static table for :status alone. Every other field went
out as Literal With Literal Name, spelling out a name the peer already holds by
index - content-type cost 12 bytes on the wire on every response.

Three tiers now, cheapest first: an entry matching name and value is one byte, a
known name is an index plus the literal value, and only an unknown name is
spelled out. Names match case-insensitively, values exactly, since a field value
is case-sensitive.

The lookup is bucketed by name length, which matters more than it looks. A plain
linear scan of the 50-odd distinct names cost 6% of throughput at this request
rate - more than the bytes it saved. Bucketed, it compares one or two candidates
and the byte saving turns into a gain:

  literal names          3,087,560 / 3,123,721 / 3,171,774 req/s
  static, linear scan    2,771,482 / 2,941,583 / 2,993,596 req/s
  static, bucketed       3,291,610 / 3,316,463 / 3,367,788 req/s

h3x, 8 connections x 100 streams, buffered playground.

Note the value ioxide sends is "text/plain; charset=utf-8" with a space, which is
not static entry 54 ("text/plain;charset=utf-8"), so it takes the name reference
rather than the one-byte index. Dropping the space would make it an exact hit,
but that changes what goes on the wire and is left alone here.

The unit tests reach Qpack through InternalsVisibleTo rather than by widening the
public surface. They verify by decoding what the encoder produces: a wrong index
still yields a well-formed section, so only a round trip catches it naming the
wrong header.
@MDA2AV
MDA2AV merged commit 7c79881 into main Aug 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant