perf(ext/fetch): cache lowercased header names per Headers instance (#33683)
## Summary
Every `appendHeader` / `Headers.has` / `Headers.get` / `Headers.set` /
`Headers.delete` / `getSetCookie` inner loop calls
`byteLowerCase(list[i][0])` for every existing entry. `byteLowerCase` is
just `String.prototype.toLowerCase`, which allocates a fresh string on
every call. For a 7-header `Headers` instance that's 7 `toLowerCase`
allocations per `.has()` / `.get()` / `.set()` call, plus an `O(n²)`
scan in `appendHeader` during construction (0+1+2+...+(N-1) redundant
lowercasings on top of the per-name input lowercasing).
This PR adds a per-instance `_lowerNames` parallel array holding the
byte-lowercased form of each `_headerList` entry. Methods that need to
compare entry names compare against `_lowerNames[i]` directly.
The cache is rebuilt from scratch when its length diverges from
`_headerList.length` — that catches the only external mutation pattern
in the codebase, where `initializeAResponse` and `Request`'s
splice/refill block push directly to `_headerList` without going through
`appendHeader` / `set` / `delete`. (Both of those mutations happen at
construction time before any read, so the rebuild cost is paid once, not
on every method call.)
`Headers.get` is also inlined here (second commit) so it can use the
same cache instead of delegating to the standalone `getHeader(list,
name)` — and so the dominant single-match case skips the intermediate
entries-array allocation and the trailing `ArrayPrototypeJoin`.
## Numbers
Same-host microbench of the six representative Headers method shapes.
`deno 2.8.1`, `V8 14.9.207.2-rusty`, Linux x86_64. 5 runs of 1M iters
(200k for `construct`), 50k-iter warmup, median (range). Baseline column
is current `origin/main` without this PR; "this PR" is current
`origin/main` with this PR applied — both built with `cargo build
--release --bin deno`.
| op | baseline (origin/main) | this PR | Δ | Node 22.22.2 | Bun 1.3.14
|
|---|---:|---:|---:|---:|---:|
| `.has(hit)` (4th-of-7) | 249.3 (241–250) | 258.1 (239–261) | ~1.0×
(noise) | 220.7 | 62.4 |
| `.has(miss)` | 475.9 (451–490) | **140.0 (137–141)** | **3.4× faster**
| 238.1 | 110.5 |
| `.get(hit)` (4th-of-7) | 543.6 (526–555) | **144.1 (134–154)** |
**3.8× faster** | 235.8 | 81.4 |
| `.get(miss)` | 455.4 (451–460) | **249.0 (233–249)** | **1.8× faster**
| 242.1 | 120.0 |
| `.set(replace)` | 809.8 (782–813) | **603.6 (578–625)** | **1.3×
faster** | 513.7 | 159.4 |
| `new Headers({...7})` | 5884.8 (5819–6233) | 5927.8 (5536–6357) |
~1.0× (noise) | 3990.7 | 1981.9 |
The wins land on the post-construction methods that middleware code
actually exercises (`request.headers.get("authorization")`,
`response.headers.set("Cache-Control", "...")`, etc.). The four shapes
that benefit cover the dominant Deno.serve hot path.
`.has(hit)` is unchanged because the existing un-cached code already
returns on the first match — the cache has nothing to optimize. Listed
for completeness.
`new Headers({...})` is dominated by webidl-converter / regex-validation
per call; the `toLowerCase` savings there are within per-iter variance.
### Cross-runtime takeaway
- `.get(hit)`: 3.8× speedup brings Deno to **144 ns vs Node 22's 236
ns** — Deno is now faster than Node on the dominant per-request Headers
read. Bun (81 ns) keeps ~1.8× lead.
- `.has(miss)`: 3.4× speedup brings Deno to **140 ns vs Node's 238 ns**
— Deno goes from ~2× *slower* than Node to **~1.7× faster**.
- `.get(miss)` / `.set(replace)`: solid wins; Deno reaches parity /
near-parity with Node.
`ext/fetch/benches/headers_methods.rs` ships with the PR so the
Rust-side `cargo bench -p deno_fetch --bench headers_methods` keeps
tracking the same shapes for CI's `bench release linux-x86_64`.
## Memory
Per `.has()` / `.get()` / `.set()` call, allocations drop from N+1 (N
entry-lowercasings + 1 input lowercasing, each a fresh string) to 1
(just the input). The per-instance `_lowerNames` array adds a fixed ~N
pointers but holds the same strings we'd have allocated lazily on first
method call anyway, so net memory is neutral-to-better. The
`Headers.get` inline change additionally drops the intermediate entries
array + Join string for the single-match case (the dominant case).
## Scope
Leaves `getHeader` / `getDecodeSplitHeader` (which take a `list` rather
than a `Headers` instance, used by `inner.headerList` callers in
`23_request.js` / `23_response.js`) and the `[_iterableHeaders]` getter
unchanged. Those can also use a cache in a follow-up but require
threading the `Headers` object through.
## Test plan
- [x] Same-host microbench across deno-with-PR / deno-without-PR / Node
22 / Bun 1.3 (numbers above)
- [x] `cargo bench -p deno_fetch --bench headers_methods` (multiple runs
before / after, both commits)
- [ ] CI: existing fetch / headers unit + spec tests (cover append, set,
delete, has, get, getSetCookie, iteration order, duplicate-name combine,
set-cookie split, immutable behaviour)
- [ ] CI: WPT `fetch/api/headers` suite
`ext/fetch` had no bench harness before this set of PRs;
`ext/fetch/benches/headers_methods.rs` is new (plus the matching
`[[bench]]` entry and `deno_bench_util` / `deno_web` / `deno_webidl`
dev-deps). C
crowlbot committed
8c47426fcecfa545251dc5b9217509a4d31e30e6
Parent: f9d0128
Committed by GitHub <noreply@github.com>
on 6/2/2026, 2:37:53 AM