Skip to content

Encode response headers against the QPACK static table - #1

Merged
MDA2AV merged 1 commit into
mainfrom
feat/static-table-response-encoding
Aug 14, 2026
Merged

Encode response headers against the QPACK static table#1
MDA2AV merged 1 commit into
mainfrom
feat/static-table-response-encoding

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 14, 2026

Copy link
Copy Markdown
Member

What

The encoder only ever consulted the static table for :status. Every other field went out as Literal With Literal Name, spelling out a name the table already holds — so a response carrying content-type, date and cache-control put all three names on the wire in full, on every response.

Response 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

content-type: text/plain is static entry 53, so it goes from 25 bytes to 1, and the header block for a small response drops from 28 bytes to 4.

Names match case-insensitively. Callers hold HTTP's conventional capitalisation (Content-Type), and resolving it here means the name is never written at all — so there is nothing to lowercase and nothing to allocate for it. Values match exactly, since a field value is case-sensitive.

Why this over the dynamic table

It works against every client. The dynamic table stays inert unless the peer advertised capacity for one, and measuring across clients, only Chrome does — curl, h3x and .NET's client all advertise 0.

Measurements

  • Header block for :status: 200 + content-type: text/plain: 28 → 4 bytes
  • Throughput on a small-response benchmark: 269,174 vs 267,640 req/s — unchanged, as expected, since that workload is not bandwidth-bound. The gain is bytes per response, and it compounds with anything that packs more responses per datagram.

Tests

129 pass (17 new). Encoding is verified by decoding what it produces, so an off-by-one index fails here rather than on a peer. Covered: exact matches across several entries, known-name-unknown-value, capitalised names, unknown names still written out, and case-sensitive value matching (TEXT/PLAIN must not resolve to entry 53).

Version

Bumped to 0.12.0 — minor rather than patch, since it changes what goes on the wire.

The encoder only ever used the static table for :status. Every other field was
written as Literal With Literal Name, spelling out a name the table already
holds - so a response carrying content-type, date and cache-control put all
three names on the wire in full, 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. content-type: text/plain is entry 53, so it goes from 25 bytes to
1, and the header block for a small response drops from 28 bytes to 4.

Names match case-insensitively. Callers hold HTTP's conventional capitalisation,
and resolving it here means the name is never written, so there is nothing to
lowercase and nothing to allocate for it. Values match exactly, since a field
value is case-sensitive.

This is worth more in practice than the dynamic table: it works against every
client, whereas the dynamic table stays inert unless the peer advertised
capacity, which only browsers do.

Throughput is unchanged on a small-response benchmark (269k vs 268k req/s),
which is expected - that workload is not bandwidth-bound. The gain is bytes per
response, and it compounds with anything that packs more responses per datagram.
@MDA2AV
MDA2AV merged commit 76de9b3 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