Skip to content

Headers: hash-index uncommon names past 64 entries so per-name lookup stays O(1) - #35087

Closed
robobun wants to merge 3 commits into
mainfrom
claude/headers-uncommon-hash-index
Closed

robobun wants to merge 3 commits into
mainfrom
claude/headers-uncommon-hash-index

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

HTTPHeaderMap stores uncommon header names in a Vector<UncommonHeader> and every get/set/append/has/delete on an uncommon name did m_uncommonHeaders.findIf(equalIgnoringASCIICase(...)), so the cost of each operation grew with the number of distinct names already present. Building a Headers with N distinct uncommon names, or reading each once, was O(N^2).

const N = 12000;
const h = new Headers();
for (let i = 0; i < N; i++) h.append("x-" + i, "v");
for (let i = 0; i < N; i++) h.get("x-" + i);
// before: ~870ms   after: ~6ms   node: ~106ms

Real servers cap ingress headers well below this, but user code can build a Headers of any size programmatically (header-mirroring proxies, exporters, tooling that stuffs metadata through the fetch API).

Fix

Add a lazily-built HashMap<String, unsigned, ASCIICaseInsensitiveHash> that maps each uncommon name to its vector index. The index is only constructed once m_uncommonHeaders.size() passes 64; below that the existing linear scan runs unchanged (one extra null check), so the common few-headers case keeps its current fast path. The index stores the String already held by the vector entry, so it costs one bucket per name rather than a second copy of each name. Mutations keep the index in sync: append records the new slot, remove drops the key and decrements the shifted slots (same O(N) as the vector shift), copy/clear/the mutable uncommonHeaders() accessor drop the index so it rebuilds on the next lookup.

Numbers (release build, append N distinct uncommon names then get each once)

N before after node
50 24us 23us 130us
100 92us 41us 115us
255 494us 78us 90us
1000 6.7ms 284us 240us
12000 868ms 6ms 106ms

Verification

test/js/web/fetch/headers.test.ts gains two tests under "many distinct uncommon names":

  • a correctness test that drives 200 distinct names through append/get/has/set/delete/copy, including mixed case and lookups after deletes have shifted vector slots, so a stale index would return the wrong value;
  • a self-calibrating lookup-time test that probes the last N names of an N-entry map and an 8N-entry map and asserts the ratio stays under 3 (O(1) lookup yields ~1, the old linear scan yielded ~11 on a release build and higher under ASAN).

All of headers.test.ts, headers.undici.test.ts, headers-case.test.ts, the Headers block of fetch.test.ts, deno/fetch/headers.test.ts, response.test.ts and cookies.test.ts pass unchanged.


[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/headers.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/167] gen cpp.rs (cppbind)
[2/167] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/167] gen JS modules (bundle-modules)
Preprocess modules (8189ms)
Bundle modules (154ms)
Postprocesss modules (197ms)
Bundle Functions (763ms)
Generate Code (26ms)

[9.35s] Bundled "src/js" for development
  2210 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/167] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[164/167] cxx obj/codegen/JSSink.cpp.o
[165/167] link bun-debug
FAILED: bun-debug 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts link --console /usr/lib/llvm-21/bin/clang++ @bun-debug.rsp -fsanitize=address -fsanitize=null -Wl,--wrap=exp -Wl,--wrap=exp2 -Wl,--wrap=expf -Wl,--wrap=fc
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (c7cfa47ee)

test/js/web/fetch/headers.test.ts:
(pass) Headers > constructor > can create headers from no arguments [0.05ms]
(pass) Headers > constructor > cannot create headers from null [0.04ms]
(pass) Headers > constructor > can create headers from empty object [0.02ms]
(pass) Headers > constructor > can create headers from object [0.02ms]
(pass) Headers > constructor > deleted key in header constructor is not kept [0.03ms]
(pass) Headers > constructor > constructing headers from an object interleaves Get with value conversion [0.07ms]
(pass) Headers > constructor > constructing headers from an object with a getter interleaves Get with value conversion [0.08ms]
(pass) Headers > constructor > constructing headers from an object observes a getter installed by an earlier value's toString [0.11ms]
(pass) Headers > constructor > constructing headers from an object propagates an exception from a getter installed by an earlier value's toString [0.07ms]
(pass) Headers > constructor > constructing headers from an object keeps own-property semantics after setPrototypeOf mid-conversion [0.05ms]
(pass) Headers > constructor > can create headers from 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/headers.test.ts
bun test v1.4.0 (c7cfa47ee)

test/js/web/fetch/headers.test.ts:
(pass) Headers > constructor > can create headers from no arguments [4.13ms]
(pass) Headers > constructor > cannot create headers from null [3.15ms]
(pass) Headers > constructor > can create headers from empty object [2.24ms]
(pass) Headers > constructor > can create headers from object [1.84ms]
(pass) Headers > constructor > deleted key in header constructor is not kept [2.46ms]
(pass) Headers > constructor > constructing headers from an object interleaves Get with value conversion [4.44ms]
(pass) Headers > constructor > constructing headers from an object with a getter interleaves Get with value conversion [6.05ms]
(pass) Headers > constructor > constructing headers from an object observes a getter installed by an earlier value's toString [7.90ms]
(pass) Headers > constructor > constructing headers from an object propagates an exception from a getter installed by an earlier value's toString [5.50ms]
(pass) Headers > constructor > constru
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 758ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[2/126] gen cpp.rs (cppbind)
[3/126] gen JS modules (bundle-modules)
Preprocess modules (7789ms)
Bundle modules (67ms)
Postprocesss modules (152ms)
Bundle Functions (695ms)
Generate Code (107ms)

[8.83s] Bundled "src/js" for production
  2040 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/126] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_uws_sys v0.0.0 (/workspace/bun/src/uws_sys)
�[1m�[92m   Compiling�[0m bun_io v0.0.0 (/workspace/bun/src/io)
�[1m�[92m   Compiling�[0m bun_uws v0.0.0 (/workspace/bun/src/uws)
�[1m�[92m   Compiling�[0m bun_zlib v0.0.0 (/workspace/bun/src/zlib)
�[1m�[92m   Compiling�[0m bun_event_loop v0.0.0 (/workspace/bun/src/event_loop)
�[1m�[92m   Compi
... (truncated)
diff hotspot
src/jsc/bindings/webcore/FetchHeaders.cpp  |  6 +-
 src/jsc/bindings/webcore/HTTPHeaderMap.cpp | 89 ++++++++++++++++++++----------
 src/jsc/bindings/webcore/HTTPHeaderMap.h   | 33 ++++++++++-
 test/js/web/fetch/headers.test.ts          | 77 ++++++++++++++++++++++++++
 4 files changed, 170 insertions(+), 35 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/jsc/bindings/webcore/FetchHeaders.cpp       1      1      0
src/jsc/bindings/webcore/HTTPHeaderMap.cpp      1      8      0
src/jsc/bindings/webcore/HTTPHeaderMap.h        2      7      0
test/js/web/fetch/headers.test.ts               4      6      0

… stays O(1)

HTTPHeaderMap stored uncommon header names in a Vector scanned linearly
on every get/set/append/has/delete, so N distinct names cost O(N) per
operation and O(N^2) to build or read once. Add a lazily-built
ASCIICaseInsensitiveHash map from name to vector index, constructed only
once the vector passes 64 entries; below that the existing linear scan
runs unchanged. The index aliases the String already held by each vector
entry, so it costs one bucket per name rather than a second copy.

Release build, N distinct uncommon names, append each then get each:

  N=50   16.6us -> 15.8us
  N=255  354us  -> 65.5us
  N=1000 4.5ms  -> 233us
  N=12k  868ms  -> 6ms
@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:38 AM PT - Jul 22nd, 2026

✅ @robobun, your commit c7cfa47ee03539b0750a200d562b2c70e314700c passed in Build #77589! 🎉


🧪   To try this PR locally:

bunx bun-pr 35087

That installs a local version of the PR into your bun-35087 executable, so you can run:

bun-35087 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. [perf]: new Headers is 3x slower compared to nodejs #20493 - Directly addresses Headers performance being slower than Node.js by optimizing uncommon header lookup from O(N²) to O(1)

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #20493

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

This does not address #20493. That issue is about new Headers() / new Headers({a, b}) construction cost at 0-2 entries, where this change is a no-op (the hash index only builds past 64 distinct uncommon names). The bottleneck there is elsewhere in the construction path.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

HTTPHeaderMap now lazily indexes uncommon headers with ASCII case-insensitive keys, maintains the index across mutations, invalidates it when storage changes, and accounts for its memory. FetchHeaders uses direct map assignment, with tests covering correctness and lookup scaling.

Changes

Uncommon header indexing

Layer / File(s) Summary
Index storage and invalidation
src/jsc/bindings/webcore/HTTPHeaderMap.h
Adds the lazy uncommon-header index, explicit copy handling, defaulted move handling, and cache invalidation for mutable operations.
Indexed lookup, mutation, and copying
src/jsc/bindings/webcore/HTTPHeaderMap.cpp, src/jsc/bindings/webcore/FetchHeaders.cpp
Uses the index for uncommon-header operations, maintains positions after removal, includes index capacity in memory accounting, and directly assigns maps when filling empty headers.
Behavior and scaling validation
test/js/web/fetch/headers.test.ts
Tests mixed-case uncommon headers across mutations and copies, plus lookup timing as the header set grows.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main performance change for uncommon header lookups.
Description check ✅ Passed The description covers what changed and how it was verified, even though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/webcore/HTTPHeaderMap.h`:
- Around line 257-260: Update uncommonHeaders() so exposing m_uncommonHeaders
permanently disables indexed lookup for the lifetime of the escaped mutable
reference; do not allow get() or related lookup paths to rebuild the index
afterward. Prefer enforcing this through the existing index state and lookup
logic, or replace the raw reference with mutation APIs that invalidate safely.

In `@test/js/web/fetch/headers.test.ts`:
- Around line 626-632: Update the explanatory comment above the lookup
performance probes to state that linear-search work is roughly 15× larger for
the 8N-entry map, reflecting that the N-entry case scans all positions while the
larger case scans only its final N positions. Keep the description of the O(1)
hash-index comparison and self-calibrating ratio unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7c5e5082-10af-40d5-8b77-a9b4c73837e3

📥 Commits

Reviewing files that changed from the base of the PR and between 3a4369b and 8438fdd.

📒 Files selected for processing (3)
  • src/jsc/bindings/webcore/HTTPHeaderMap.cpp
  • src/jsc/bindings/webcore/HTTPHeaderMap.h
  • test/js/web/fetch/headers.test.ts

Comment thread src/jsc/bindings/webcore/HTTPHeaderMap.h Outdated
Comment thread test/js/web/fetch/headers.test.ts Outdated
…e map directly

The only caller of the non-const accessor was FetchHeaders::fill filling
an empty map, which is now a straight copy of the source HTTPHeaderMap.
Removing the accessor means no caller can hold a mutable vector reference
across a lookup that rebuilds the index.

Also correct the linear-scan ratio in the test comment (probing the last
N of an 8N-entry map averages ~7.5N scan depth vs ~N/2 for the N-entry
map, so ~15x, not 8x).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I also checked: all remaining uncommonHeaders() callers (NodeHTTP.cpp, JSFetchHeaders.cpp) are read-only iteration, so dropping the mutable overload is safe; setInternalHeaders was a plain move-assign, so the FetchHeaders::fill copy-assign is behavior-equivalent; and the HashMap holds its own String ref, so removeAt(index) before m_uncommonHeadersIndex->remove(name) doesn't dangle the key.

Extended reasoning...

This is a performance optimization to a core WebCore data structure (HTTPHeaderMap) that adds a lazily-built secondary index with new copy/move ctors, invalidation logic across every mutation path, and a wall-clock timing test. The C++ looks correct on inspection and I ruled out a few specific concerns (noted in the message), but the change is neither simple nor mechanical — index-coherence bugs here would surface as wrong header values in HTTP responses, so it warrants a human look rather than auto-approval. The one finding posted inline is a test-quality nit about the timing assertion degenerating to an absolute bound on release builds.

Comment thread test/js/web/fetch/headers.test.ts
At N=1500 gets the release-build baseline was ~0.3ms, so the 1ms floor
turned the ratio assertion into an absolute tLarge < 3ms bound. Cycle
20000 gets through the same N names instead; the release baseline is now
~1.3ms, the floor is gone, and the ratio self-calibrates on every build.
best-of-2 absorbs single scheduler blips.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find correctness issues, but this adds a new data member with copy/move/clear lifecycle to HTTPHeaderMap (present on every Request/Response) and picks a threshold constant on a hot path, so it's worth a maintainer sign-off on the approach.

What was reviewed:

  • Index lifecycle across copy ctor/assign, move, clear(), isolatedCopy(), shrinkToFit() — all leave or reset the index consistently with the vector.
  • removeUncommonHeader: vector removeAt shifts before the index is walked; the HashMap key is a refcounted String so it survives the vector entry's destruction; decrement loop matches the shift.
  • Removed mutable uncommonHeaders(): remaining callers (NodeHTTP.cpp, JSFetchHeaders.cpp, the iterator) all reach it via const HTTPHeaderMap& so they resolve to the const overload.
  • FetchHeaders::fill rewrite is equivalent to the old appendVector+move path (setInternalHeaders was just a move-assign).
Extended reasoning...

Overview

Adds a lazily-built HashMap<String, unsigned, ASCIICaseInsensitiveHash> to HTTPHeaderMap that indexes m_uncommonHeaders by name once the vector exceeds 64 entries, so per-name operations stay O(1) instead of degrading to O(N). Touches HTTPHeaderMap.{h,cpp} (new member, custom copy ctor/assign, defaulted move, findUncommonHeaderIndex/appendUncommonHeader helpers, index maintenance in removeUncommonHeader, memoryCost accounting), FetchHeaders.cpp (replaces the vector-append fill path with direct copy-assign, which also let the mutable uncommonHeaders() accessor be deleted), and adds two tests to headers.test.ts.

Security risks

None identified. The change is an internal lookup optimization; no new user-controlled input reaches new parsing or allocation logic. Header names are already validated by isValidHTTPToken before reaching these paths, so the empty-String HashMap sentinel is unreachable.

Level of scrutiny

Moderate-to-high. HTTPHeaderMap sits under every Headers, Request, and Response; the added unique_ptr field and custom copy/move machinery run on the common path even when the index is never built. The threshold of 64 and the decision to maintain (rather than invalidate) the index on delete are design choices a maintainer should confirm. The Performance section of the landing-PRs doc applies.

Other factors

Correctness looks solid: every mutation site that touches m_uncommonHeaders now routes through appendUncommonHeader/removeUncommonHeader or leaves indices unchanged; clear(), copy-assign, and isolatedCopy() all reset/omit the index; shrinkToFit() doesn't need to (indices are stored, not pointers). The removed mutable accessor's remaining call sites all go through internalHeaders() which returns const&, so they bind to the const overload. The correctness test exercises get/has/set/append/delete across the threshold with mixed case and post-delete shifts. The timing test was reworked in c7cfa47 after earlier feedback so the baseline clears 1ms on release and the ratio genuinely self-calibrates (~1.1 pass vs ~11 regression, threshold 3) — reasonable, though timing-ratio tests always carry some residual flake risk. Deferring so a human can sign off on the hot-path design and the +8 bytes per map.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Build #77589: headers.test.ts and the rest of the diff passed on every lane that ran (194 jobs). The only red is two darwin 14 aarch64 - test-bun jobs that expired without ever starting (agent unavailable); darwin 26 aarch64 ran the same tests and passed. The remaining annotations are known flakes that passed on retry (test-http-server-connections-checking-leak, es-module-lexer, multi-run, undici-h2, etc.), none touching Headers/HTTPHeaderMap. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants