Skip to content

perf: lazy-init mutable Headers copy in headersContextFromRequest#388

Merged
james-elicx merged 2 commits intomainfrom
perf/lazy-headers-context
Mar 9, 2026
Merged

perf: lazy-init mutable Headers copy in headersContextFromRequest#388
james-elicx merged 2 commits intomainfrom
perf/lazy-headers-context

Conversation

@james-elicx
Copy link
Copy Markdown
Collaborator

Problem

headersContextFromRequest was profiled at 815ms self-time per request with no children — all time spent in its own body. The two culprits:

  1. new Headers(request.headers) — in Workerd, this copies the entire header map across the V8/C++ boundary. On requests with many headers (e.g. a browser sending lots of cookies), this is very slow.
  2. Cookie parsing — splitting the cookie header on ; and = on every request, even when cookies() is never called.

Both operations happened unconditionally on every request entry, even for routes that never read cookies or mutate headers.

Fix

Lazy mutable Headers proxy

Replace new Headers(request.headers) with a Proxy around the original immutable request.headers:

  • Reads (get, has, entries, forEach, …) are forwarded directly to request.headers — zero copy cost.
  • First write (set, delete, or append) materialises new Headers(request.headers) once and caches it. All subsequent operations use the copy.

The copy is only needed when middleware calls NextResponse.next({ request: { headers } }) and applyMiddlewareRequestHeaders() sets headers on the context. That path is uncommon. Pure read requests (the vast majority) now pay zero V8/C++ boundary crossing for headers.

Lazy cookie parsing

Cookie parsing is moved behind a lazy getter on the cookies property. The cookie header string is not split until cookies(), draftMode(), or applyMiddlewareRequestHeaders() is first invoked for a request. Requests that don't touch cookies skip the parse entirely.

Tests

Four new test cases added to tests/shims.test.ts:

  • defers new Headers() copy until first write — regression guard: reads must work before any write
  • defers cookie parsing until first access — cookies map is not populated until accessed
  • cookie getter reflects middleware-modified cookie header — verifies applyMiddlewareRequestHeaders correctly invalidates and rebuilds the cookie map after middleware changes the cookie header
  • Existing mutability and correctness tests all pass unchanged (581 tests, 0 failures)

Behavioural compatibility

The HeadersContext interface is unchanged. setHeadersContext({ headers, cookies }) (used by tests and legacy callers) continues to accept plain Headers/Map objects without change.

In Workerd, new Headers(request.headers) copies the entire header map
across the V8/C++ boundary on every request, showing up as ~815ms self-
time in production profiles for header-heavy requests.

Replace the eager copy with a Proxy that forwards reads directly to the
immutable request.headers. The mutable copy is only materialised on the
first write (.set/.delete/.append), which only occurs when middleware
calls NextResponse.next({ request: { headers } }). Pure read requests
(the vast majority) now pay zero copy cost.

Cookie parsing is also deferred behind a lazy getter: the cookie string
is not split until cookies() or draftMode() is first called during the
request, rather than unconditionally on every request entry.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@388

commit: add3fbf

@james-elicx james-elicx changed the title perf: lazy-init mutable Headers copy in headersContextFromRequest (~815ms savings) perf: lazy-init mutable Headers copy in headersContextFromRequest Mar 9, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 9, 2026

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@james-elicx
Copy link
Copy Markdown
Collaborator Author

/bigbonk review

@james-elicx james-elicx marked this pull request as ready for review March 9, 2026 20:07
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good optimization. The lazy proxy approach correctly defers the expensive new Headers(request.headers) copy until a mutation actually occurs, and cookie parsing is deferred until first access. The proxy implementation is sound — reads forward to the original immutable headers, writes materialize a copy once, and subsequent operations use that copy.

All 581 tests pass, including the 4 new regression guards.

A few observations:

MUTATING_METHODS Set allocated per request

The new Set(["set", "delete", "append"]) at line 263 is created inside headersContextFromRequest, meaning every request allocates a new Set. This is cheap but unnecessary — hoist it to module scope since the contents are static:

const MUTATING_METHODS = new Set(["set", "delete", "append"]);

export function headersContextFromRequest(request: Request): HeadersContext {
  // ...use MUTATING_METHODS from module scope...

Stale cookie map after direct header mutation

The lazy cookie getter memoizes on first access (if (_cookies) return _cookies). Once parsed, the _cookies map is never invalidated. applyMiddlewareRequestHeaders works correctly because it explicitly calls ctx.cookies.clear() + repopulates. But if anything outside that function calls ctx.headers.set("cookie", "new=value") directly, the cached _cookies map will be stale.

This is safe today because the only cookie-header mutation path goes through applyMiddlewareRequestHeaders, and the old code had the same disconnect (cookies were a separate eagerly-parsed Map). Worth a comment noting this invariant, but not blocking.

Minor: wasteful parse on first middleware cookie rebuild

When applyMiddlewareRequestHeaders is called and cookies haven't been accessed yet, ctx.cookies.clear() triggers getCookies(), which parses the pre-middleware cookie header into a new Map, then immediately clear()s it and repopulates from the post-middleware header. This does one unnecessary parse. Not worth fixing — it only happens on the middleware path which is already paying for the Headers copy.

Overall this is a clean, well-tested change. The proxy approach is the right tradeoff for the Workerd environment.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Mar 9, 2026

Review posted. Here's a summary of what I found:

Approved — the lazy proxy approach is correct and well-tested. Three non-blocking suggestions:

  1. Hoist MUTATING_METHODS to module scope — it's a static set allocated on every request currently (line 263)
  2. Stale cookie map invariant — the memoized _cookies map won't reflect direct ctx.headers.set("cookie", ...) calls outside applyMiddlewareRequestHeaders. Safe today since all mutations go through that function, but worth documenting.
  3. Minor wasteful parse — when applyMiddlewareRequestHeaders triggers the lazy cookie getter for the first time, it parses the pre-middleware cookies only to immediately clear and repopulate. Not worth fixing since this path already pays for the Headers copy.

github run

It was being allocated on every request inside headersContextFromRequest.
@james-elicx james-elicx enabled auto-merge (squash) March 9, 2026 20:16
@james-elicx james-elicx merged commit 696936c into main Mar 9, 2026
26 checks passed
@james-elicx james-elicx deleted the perf/lazy-headers-context branch March 9, 2026 20:19
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