Skip to content

Resolve common Accept-Encoding values without parsing them - #741

Merged
mtrudel merged 1 commit into
mtrudel:mainfrom
mpettibone:encoding
Nov 4, 2026
Merged

mtrudel merged 1 commit into
mtrudel:mainfrom
mpettibone:encoding

Conversation

@mpettibone

@mpettibone mpettibone commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

(Claude helped with test cases)

Every request that carries an accept-encoding header goes through Bandit.Compression.negotiate_content_encodings/2 in Bandit.Adapter.init/5, before the plug runs and whatever the response turns out to be. The parser there is general on purpose: it handles q-values, the wildcard, odd spacing and odd casing. That generality has a per-request price. Plug.Conn.Utils.list/1 splits the value, then every coding gets a String.split, a String.trim and a String.downcase, then there is a List.keyfind per configured encoding. For the value Chrome sends that adds up to 5 µs per request in my test VM. Now that #740 has stopped small responses paying for zlib, that parse is the largest cost left tied to the header: about 8% of a small JSON API request there, spent reaching the same answer every time.

Because it is the same answer every time. Nearly all traffic carries one of about five values: browsers send gzip, deflate, br, zstd (Chrome, Edge, Firefox) or gzip, deflate, br (Safari) over https and gzip, deflate over plain http, curl sends deflate, gzip, br, zstd, and Go's and the JVM's HTTP clients send a bare gzip. None of them vary in spacing or case. So this PR resolves those five values with a pattern match on the exact string and leaves everything else to the parser.

How it works

@common_accept_encodings pairs each value with the codings the client is accepting. A for over that list generates one common_negotiation/2 clause per value. The clause also matches the default encodings list as its second argument, and its body is the negotiation result, computed at compile time by filtering the default encodings down to the accepted ones. A hit therefore costs the two option lookups that happened anyway plus one literal match, and returns a literal.

Everything else falls through to the parser, which is unchanged apart from moving into parse_and_negotiate/2:

  • a value that isn't in the table, including one of the five with different spacing or case;
  • response_encodings set to anything but the default, since the second argument then doesn't match and the parser runs with the configured list exactly as before;
  • compress: false, where the early return is untouched.

Why this shape

The parser stays the one definition of the negotiation rules. The table is a cache of its answers for the inputs that matter, not a second implementation of them, and the tests hold it to that: for each of the five values, the fast path result must equal what the parser returns for the same value upcased and for the same value with a trailing space, two spellings the table never matches and the parser normalizes to the same thing. If the parser or the default encodings change and the table is left behind, those tests fail.

Resolving the table at compile time also means the results follow the platform. On OTP 28 and later, where :zstd is available and in the default encodings, gzip, deflate, br, zstd resolves to ["zstd", "gzip", "deflate"]; on earlier releases it resolves to ["gzip", "deflate"], the same as the parser produces in each case.

I considered a runtime memo of recent values instead. Per process it would be wiped by Bandit's own process dictionary clearing between keepalive requests, and anything longer lived would need state threaded through the handler and a story for invalidation. A fixed table covers the values that account for nearly all requests with no state at all, and adding a value is one line. I kept it to five rather than chasing every client library, because each row is something to keep right, and a value that isn't in the table still works, it just pays the parse.

Performance

Measured against main at 4812459 (which includes #740) in a 2-core VM at 2.1 GHz with the BEAM pinned to one core running one scheduler, OTP 25.3, Elixir 1.17.3. The absolute numbers are slow and the VM is noisy; the ratios are the point.

Cost of negotiate_content_encodings/2 itself, per call. Each cell is the best of three sessions of three runs of a million calls, the usual way to get a stable number for a CPU-bound function on a shared host.

Accept-Encoding value Typical sender main This PR
gzip, deflate, br, zstd Chrome, Edge, Firefox (https) 5,053 ns 70 ns
gzip, deflate, br Safari (https) 3,887 ns 69 ns
gzip, deflate browsers over http, Python requests 2,636 ns 66 ns
deflate, gzip, br, zstd curl --compressed 5,305 ns 63 ns
gzip Go net/http, OkHttp 1,935 ns 62 ns
gzip;q=0.8, deflate;q=0.5, br anything else, via the parser 6,148 ns 5,879 ns
no header 10 ns 10 ns

End to end: HTTP/1 keepalive over loopback, 32 connections, wrk pinned to the other core, 8 second runs, the two builds alternated and compared in pairs, medians of seven pairs. The last row runs identical code on both builds, so it shows the noise floor of this VM.

Request main req/s This PR req/s Paired change
15 B JSON, Chrome accept-encoding only 15,936 17,035 +6.9% (range +4% to +22%)
13 B text, full Chrome header set 11,870 14,375 +18% (range -2% to +28%)
15 B JSON, no accept-encoding (control) 18,641 19,846 +6.5% (range -8% to +16%)

CPU per request for the same runs, user plus system time divided by requests served:

Request main This PR
15 B JSON, Chrome accept-encoding only 62.0 µs 56.9 µs
13 B text, full Chrome header set 83.3 µs 68.8 µs
15 B JSON, no accept-encoding (control) 52.9 µs 49.8 µs

The control row moved by up to 16% between paired runs with nothing changed, so read the other rows against that. The CPU saving in paired medians was 5 µs for the JSON shape, which is what the micro-benchmark predicts (a 5 µs parse replaced by 70 ns), and 12 µs for the browser shape, which is more than the parse alone can account for and is best read as noise-inflated. The sound estimate is the arithmetic: 5 µs off a 62 µs request, so around 8% more requests per core for API traffic from browsers, a little less for page loads with their longer header lists, and nothing for clients that don't send the header.

The saving applies to HTTP/2 as well, since the negotiation runs in the shared adapter.

Tests

test/bandit/compression_negotiation_test.exs is new: the five equivalence tests described above, the expected result for each table value with the platform dependent zstd handled, a configured response_encodings and compress: false taking the general path, a few values outside the table still parsing, and two requests through a real HTTP/1 server with a browser's value and with gzip. The tests pass on main as well, by design; the change is meant to be invisible, and they are there to keep it that way. Suite, format, credo and dialyzer are clean.

Every request that carries an `accept-encoding` header goes through
`Bandit.Compression.negotiate_content_encodings/2` in `Bandit.Adapter.init/5`, before the plug
runs and whatever the response turns out to be. The parser there is general on purpose: it handles
q-values, the wildcard, odd spacing and odd casing. That generality has a per-request price.
`Plug.Conn.Utils.list/1` splits the value, then every coding gets a `String.split`, a `String.trim`
and a `String.downcase`, then there is a `List.keyfind` per configured encoding. For the value
Chrome sends that adds up to 5 µs per request in my test VM. Now that mtrudel#740 has stopped small
responses paying for zlib, that parse is the largest cost left tied to the header: about 8% of a
small JSON API request there, spent reaching the same answer every time.

Because it is the same answer every time. Nearly all traffic carries one of about five values:
browsers send `gzip, deflate, br, zstd` (Chrome, Edge, Firefox) or `gzip, deflate, br` (Safari)
over https and `gzip, deflate` over plain http, curl sends `deflate, gzip, br, zstd`, and Go's and
the JVM's HTTP clients send a bare `gzip`. None of them vary in spacing or case. So this PR
resolves those five values with a pattern match on the exact string and leaves everything else to
the parser.

## How it works

`@common_accept_encodings` pairs each value with the codings the client is accepting. A `for`
over that list generates one `common_negotiation/2` clause per value. The clause also matches the
default encodings list as its second argument, and its body is the negotiation result, computed
at compile time by filtering the default encodings down to the accepted ones. A hit therefore
costs the two option lookups that happened anyway plus one literal match, and returns a literal.

Everything else falls through to the parser, which is unchanged apart from moving into
`parse_and_negotiate/2`:

- a value that isn't in the table, including one of the five with different spacing or case;
- `response_encodings` set to anything but the default, since the second argument then doesn't
  match and the parser runs with the configured list exactly as before;
- `compress: false`, where the early return is untouched.

## Why this shape

The parser stays the one definition of the negotiation rules. The table is a cache of its answers
for the inputs that matter, not a second implementation of them, and the tests hold it to that:
for each of the five values, the fast path result must equal what the parser returns for the same
value upcased and for the same value with a trailing space, two spellings the table never matches
and the parser normalizes to the same thing. If the parser or the default encodings change and
the table is left behind, those tests fail.

Resolving the table at compile time also means the results follow the platform. On OTP 28 and
later, where `:zstd` is available and in the default encodings, `gzip, deflate, br, zstd` resolves
to `["zstd", "gzip", "deflate"]`; on earlier releases it resolves to `["gzip", "deflate"]`, the same
as the parser produces in each case.

I considered a runtime memo of recent values instead. Per process it would be wiped by Bandit's
own process dictionary clearing between keepalive requests, and anything longer lived would need
state threaded through the handler and a story for invalidation. A fixed table covers the values
that account for nearly all requests with no state at all, and adding a value is one line. I kept
it to five rather than chasing every client library, because each row is something to keep right,
and a value that isn't in the table still works, it just pays the parse.

## Performance

Measured against `main` at `4812459` (which includes mtrudel#740) in a 2-core VM at 2.1 GHz with the BEAM
pinned to one core running one scheduler, OTP 25.3, Elixir 1.17.3. The absolute numbers are slow
and the VM is noisy; the ratios are the point.

Cost of `negotiate_content_encodings/2` itself, per call. Each cell is the best of three sessions
of three runs of a million calls, which is the usual way to get a stable number for a CPU-bound
function on a shared host:

| `accept-encoding` value | Sent by | `main` | This PR |
| --- | --- | ---: | ---: |
| `gzip, deflate, br, zstd` | Chrome, Edge, Firefox over https | 5,053 ns | 70 ns |
| `gzip, deflate, br` | Safari over https | 3,887 ns | 69 ns |
| `gzip, deflate` | browsers over plain http, Python requests | 2,636 ns | 66 ns |
| `deflate, gzip, br, zstd` | curl `--compressed` | 5,305 ns | 63 ns |
| `gzip` | Go `net/http`, OkHttp | 1,935 ns | 62 ns |
| `gzip;q=0.8, deflate;q=0.5, br` | anything else, through the parser | 6,148 ns | 5,879 ns |
| no header | | 10 ns | 10 ns |

End to end, HTTP/1 keepalive over loopback with 32 connections, wrk pinned to the other core, 8
second runs, the two builds alternated and compared in pairs. Medians of seven pairs; the range
of the paired differences is in the last column because this VM moves a lot between runs:

| Request | `main` req/s | This PR req/s | CPU per request, `main` | CPU per request, this PR | Paired change in req/s |
| --- | ---: | ---: | ---: | ---: | --- |
| 15 byte JSON, `accept-encoding: gzip, deflate, br, zstd` only | 15,936 | 17,035 | 62.0 µs | 56.9 µs | +6.9% (range +4% to +22%) |
| 13 byte text, full Chrome header set | 11,870 | 14,375 | 83.3 µs | 68.8 µs | +18% (range -2% to +28%) |
| 15 byte JSON, no `accept-encoding` (identical code on both) | 18,641 | 19,846 | 52.9 µs | 49.8 µs | +6.5% (range -8% to +16%) |

The last row runs identical code on both builds, so it is the noise floor: paired runs disagreed
by up to 16% with nothing changed. Read the other two rows against that. The per-request CPU
saving in paired medians was 5 µs for the JSON shape, which is what the micro-benchmark predicts
(5 µs parse replaced by 70 ns), and 12 µs for the browser shape, which is more than the parse
alone can account for and is best read as noise-inflated. The sound estimate is the arithmetic:
5 µs off a 62 µs request, so around 8% more requests per core for API traffic from browsers, a
little less for page loads with their longer header lists, and nothing for clients that don't
send the header.

The saving applies to HTTP/2 as well, since the negotiation runs in the shared adapter.

## Tests

`test/bandit/compression_negotiation_test.exs` is new: the five equivalence tests described
above, the expected result for each table value with the platform dependent `zstd` handled, a
configured `response_encodings` and `compress: false` taking the general path, a few values
outside the table still parsing, and two requests through a real HTTP/1 server with a browser's
value and with `gzip`. The tests pass on `main` as well, by design; the change is meant to be
invisible, and they are there to keep it that way. Suite, format, credo and dialyzer are clean.
@mtrudel
mtrudel merged commit 1a3efe9 into mtrudel:main Nov 4, 2026
26 of 29 checks 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.

2 participants