Skip to content

node:http: server hot-path performance (stacked on #32488) - #33879

Merged
cirospaciari merged 20 commits into
claude/node-http-http2-compatfrom
claude/http-server-hotpath-opts
Jul 10, 2026
Merged

cirospaciari merged 20 commits into
claude/node-http-http2-compatfrom
claude/http-server-hotpath-opts

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 10, 2026 •

Copy link
Copy Markdown
Member

Stacked on #32488. Commits that recover the node:http server throughput regression from the compat rewrite and push well past the previous release.

What this does

The compat rewrite in #32488 costs ~5% on a plain keep-alive http.createServer workload versus v1.3.14. This PR removes per-request work from the server hot path until the same workload is +8.8% faster than v1.3.14, without changing behavior: the full vendored test/js/node/test/parallel/test-http-* suite (376 files) passes identically before and after every commit here.

Numbers

res.writeHead(200, {...}); res.end("Hello World") under oha -c 64, LTO release builds, server pinned to 2 cores, load generator on separate cores, 7 reps interleaved round-robin across builds (so machine drift hits every build equally), medians:

build req/s vs 1.3.14
bun 1.3.14 123,242 —
#32488 as-is ~116,500 −4.9%
this PR 134,033 +8.8%

The minimum rep of this PR (131,987) is above the maximum of every other build. Bun.serve, WebSocket server/client, and the HTTP clients are unaffected (measured flat).

What changed

  • Lazy request headers + native dispatch bitfield: the native side no longer builds a 2N-string rawHeaders array per request. It captures the raw header bytes on the response handle (flat buffer, stack-inline up to 1 KiB) and computes an 8-bit bitfield for the headers the dispatcher itself needs (connection close/upgrade tokens, host/expect/content-length/transfer-encoding presence). req.headers / req.rawHeaders materialize on first user access via handle.takeRawHeaders(). A handler that never reads headers does zero header materialization.
  • Native framework headers: Date, Connection: keep-alive/close, and Keep-Alive: timeout=N are no longer marshalled as JS strings; the renderer passes two integers and the native side appends cached byte blobs (the Date line rebuilt at most once per second; both keep-alive lines as a single buffer write). Wire order and semantics (sendDate=false, user-set Date/Connection, maxRequestsPerSocket) unchanged.
  • writeHeadAndEnd: one native call (and one native cork) for the writeHead+end pair, replacing cork(() => { writeHead(); end(); }) — three crossings and a closure per response. Same headersSent semantics on every error path.
  • Fewer native crossings: reuse the already-read flags bitfield in end() instead of separate ended/aborted getters; skip the per-request ondata/ondrain clears when already cleared; skip takeRequestTrailers for body-less requests; index kOutHeaders directly in the trailer checks.
  • Per-request allocation removal: reusable backing array in renderNativeHeaders (consumed synchronously by native writeHead; re-entrancy degrades to a fresh array), scratch options object for the builtin ServerResponse, socket-cached bound abort handler, finish listeners via on() instead of once(), hoisted nextTick callback.
  • Assorted fixes found along the way: the kUniqueHeaders stamp shape-transitioned every response even when the option was unset (null passed an !== undefined guard); _isLenientHeaderValidation walked req.socket.server twice per setHeader.

How it's verified

  • 376-file vendored Node http suite A/B against the base at every commit boundary: zero regressions (flaky pairs re-run serially 5x to confirm).
  • Exact wire-format probes: Date header format byte-for-byte, header order on the wire, sendDate=false, explicit Connection: close, the maxRequestsPerSocket Keep-Alive variant, exactly one Date line when the user supplies their own.
  • Behavior probes for the touchy paths: duplicate-header joining, wire casing, post-teardown req.headers access, Expect: 100-continue, chunked trailers, HEAD, pipelined requests, abort delivery.
  • The lazy path preserves Node's duplicate-handling rules (first-wins vs comma-joining per header) and maxHeadersCount truncation.

Profiled with --cpu-prof against the same LTO build of main. Both changes
were confirmed by re-profiling the patched build: each eliminates the
per-request CPU it targets, and an unfixed-vs-fixed A/B over all 376
vendored test-http-*.js Node tests produces an identical pass set.

renderNativeHeaders: stamping res[kUniqueHeaders] on every ServerResponse,
even when the server never set the uniqueHeaders option, forces a
hidden-class transition per response that de-optimizes later property
access on it (renderNativeHeaders + its Array.push grew from 4.8% to 6.6%
of JS samples on a keep-alive hello workload). Only stamp the symbol when
the option is actually set.

keep-alive idle timeout: socket.setTimeout() on every response finish
reduces to a timer refresh() per request (0.95% of JS samples on a
16-chunk write workload). Record when the idle period started instead and
leave the armed timer alone; when it fires, onSocketTimeoutTimerExpired
grants the remaining budget once. The socket still closes after exactly
keepAliveTimeout+buffer ms of idle, but the timer reschedule moves from
once per response to at most once per idle period.
…s per request

perf (--cpu-prof) on the node:http server shows the request dispatcher
registering up to three 'finish' listeners per request, each through a
fresh Function.prototype.bind: onResponseFinishHandleSocket, detachSocket,
and advanceResponsePipeline. Registration (once -> addListener -> _onceWrap)
and dispatch of the bound functions together cost about 1.2% of JS samples
on a 16-chunk write workload.

Inside a 'finish' listener the response is `this`, the response still
holds its socket, and the socket carries its owning server, so none of the
bound arguments are needed. Replace the three registrations with two shared
module-level functions and no bind: one for the keep-alive/close half and
one that detaches then advances the pipeline (the same order as before).
The pipelined-response replay path used the identical pair of bound
listeners and gets the same shared function.

Also read res[kUniqueHeaders] inside the multi-valued-header branch of
renderNativeHeaders instead of once per render: it is only consulted for
array-valued headers, and for every response whose server never set the
uniqueHeaders option the hoisted read was a prototype-chain miss.
…e header bitfield

The server dispatched every request with an eagerly built rawHeaders array
(2N strings + a JSArray per request), and the dispatcher itself read
req.headers five to six times per request, forcing the lazy header-object
build even when the handler never touches headers.

Now the native side captures the raw header bytes on the response handle
(one flat buffer, stack-inline up to 1 KiB) and computes an 8-bit dispatch
bitfield (connection close/upgrade tokens, host/expect/content-length/
transfer-encoding presence) in the same pass. req.rawHeaders and req.headers
materialize on first user access via handle.takeRawHeaders(), which reuses
the existing array-building code. A request whose handler reads neither does
no header materialization at all.

Also: memoize the per-response keep-alive decision and
_isLenientHeaderValidation (previously recomputed per setHeader with two
socket->server option walks), fix the kUniqueHeaders stamp to skip the null
case (it was shape-transitioning every response for servers that never set
uniqueHeaders), and hoist repeated symbol/property reads in
renderNativeHeaders, the finish handler, and the headers getter.
- reuse the flags bitfield already read in end() instead of paying separate
  native getters for ended/aborted (the enum now mirrors all native bits)
- skip the per-request ondata/ondrain native setter writes when streaming is
  already disabled on the socket (the common keep-alive case rewrites
  undefined over undefined twice per request)
- skip takeRequestTrailers for requests without a body (trailers only follow
  a chunked body)
- willBeChunked/hasInvalidTrailer index kOutHeaders directly instead of
  hasHeader/getHeader chains (each re-read the symbol and lowercased its
  argument)
- renderNativeHeaders reuses a module-level backing array (the native
  writeHead consumes it synchronously; a busy flag degrades to a fresh array
  on re-entrancy or exceptions)
- cache the Keep-Alive header value per server (keepAliveTimeout is a server
  constant, the string was rebuilt per request)
- reuse a scratch options object for the builtin ServerResponse construction
  (the constructor chain only reads it; user subclasses keep a fresh object)
- cache the bound abort handler on the socket instead of bind() per request
res.end() previously crossed into native three times per response
(cork + writeHead + end) and allocated a closure for the cork callback.
writeHeadAndEnd runs both phases under a single native cork in one crossing,
with the same headersSent semantics on every error path (the write-head
phase's gate/validation errors leave the state unset; later errors mean
headers reached the wire, matching end() throwing after writeHead).

Also register the shared finish listeners with on() instead of once() -
finish fires at most once per response and once() allocates a wrapper
closure per request.
@robobun

robobun commented Jul 10, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 2:44 AM PT - Jul 10th, 2026

❌ @cirospaciari, your commit 5663326 has 2 failures in Build #71412 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33879

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

bun-33879 --bun

autofix-ci Bot and others added 3 commits July 10, 2026 01:15
The inline arrow allocated a function object per response.
…an enum read

continueExpression and RE_CONN_UPGRADE lost their last readers when the
dispatcher moved to the native header bitfield; NodeHTTPHeaderState.sent was
read in end()'s condition and again in the body.
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_incoming.ts
Comment thread src/jsc/bindings/NodeHTTP.cpp
…blobs

renderNativeHeaders was pushing up to six framework strings per response
(Date, Connection: keep-alive / close, Keep-Alive: timeout=N) into the flat
header array, each of which the native side then converted and wrote through
the per-header path (name lookup + four buffer writes per header).

The renderer now reports them as two integers (an auto-header bitfield and
the keep-alive timeout) and the native side appends cached byte blobs: the
Date line is rebuilt at most once per second (hand-rolled, locale-
independent), and both keep-alive lines go out as a single buffer write.
Header order on the wire is unchanged (user headers, then Date, Connection,
Keep-Alive). sendDate=false, user-supplied Date/Connection headers, and the
maxRequestsPerSocket Keep-Alive variant keep their exact semantics (the last
renders in JS as before).

hello-world throughput: +4.7% on top of the previous commit, +8.8% total
over v1.3.14 (interleaved 7-rep medians, same run).

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/_http_server.ts:3173-3180 — In write(), _send(), and flushHeaders(), if handle.writeHead throws (e.g. RangeError for statusCode outside 100–999, or ERR_INVALID_CHAR on the status message), releaseRenderedHeaders(renderedHeaders) is never reached and scratchFlatHeadersBusy stays true for the process lifetime — every later renderNativeHeaders() then permanently falls back to flat = []. end() already handles this with a catch-path release; consider wrapping the writeHead call in try { ... } finally { releaseRenderedHeaders(renderedHeaders) } at these three sites for consistency. Perf-only (correctness is unaffected — the fallback is exactly the pre-PR behavior).

    Extended reasoning...

    What this is

    renderNativeHeaders() sets the module-level scratchFlatHeadersBusy = true and hands out the shared scratchFlatHeaders array; the caller is expected to call releaseRenderedHeaders(flat) afterward, which flips the busy flag back to false when flat === scratchFlatHeaders. ServerResponse.prototype.end() (~line 3020) does this correctly on both the success and the catch path — it calls releaseRenderedHeaders(renderedHeaders) before rethrowing. But write() (~3173–3180), _send() (~3350), and flushHeaders() (~3461) call handle.writeHead(..., renderedHeaders) followed directly by releaseRenderedHeaders(renderedHeaders) with no try/catch or try/finally.

    The specific code path

    The native write_head_impl (Rust) can throw on user-controlled input that passes the JS-side gates:

    • validate_integer_range throws RangeError when statusCode < 100 or > 999.
    • ERR_INVALID_CHAR when statusMessage contains a control byte.
    • ERR_HTTP_HEADERS_SENT / ERR_STREAM_ALREADY_FINISHED from the state gate.

    For write() and _send() the implicit-header path (headerStateSymbol === 'none') reaches handle.writeHead with this.statusCode unvalidated in JS, so res.statusCode = 99; res.write('x') throws inside the cork closure after renderNativeHeaders has already set the busy flag.

    Why nothing else clears it

    scratchFlatHeadersBusy is a module-level let and releaseRenderedHeaders is the only place that writes false to it. Once a throw skips that call, the flag is stuck for the process lifetime. Every subsequent renderNativeHeaders() — for every response on the server — then takes the flat = [] fresh-allocation branch, permanently defeating the reusable-array optimization. As a secondary effect, scratchFlatHeaders itself retains the failing request's rendered header strings indefinitely (the next call never runs scratchFlatHeaders.length = 0). The comment on scratchFlatHeaders says exception paths "degrade to a fresh array, which is exactly the previous behavior" — that's true for later calls, but it doesn't note that the degradation is permanent rather than per-call, and end()'s explicit catch-path release suggests permanent degradation wasn't the intent.

    Step-by-step

    1. Client sends a request; handler does res.statusCode = 99; res.write('x').
    2. write() sees headerStateSymbol !== sent, enters handle.cork(() => { ... }).
    3. renderNativeHeaders(this) runs: scratchFlatHeadersBusy = true, returns scratchFlatHeaders.
    4. handle.writeHead(99, ...) reaches native validate_integer_range, which throws RangeError (min 100).
    5. The exception propagates out of the cork closure; releaseRenderedHeaders(renderedHeaders) on the next line never executes.
    6. scratchFlatHeadersBusy is now true forever. Every future response's renderNativeHeaders() sees it set and allocates flat = [] instead of reusing the scratch array.

    Impact and fix

    Perf-only — correctness is unaffected (a fresh array per call is exactly what the code did before this PR), and it takes an application-level bug (out-of-range status code / bad status message on a non-end() path) to trigger. The tidy fix is to wrap the handle.writeHead(...) call in try { ... } finally { releaseRenderedHeaders(renderedHeaders) } at the three unguarded sites, matching what end() already does. Not worth blocking on.

Comment thread src/jsc/bindings/NodeHTTP.cpp Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/NodeHTTP.cpp
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/NodeHTTP.cpp
Comment thread src/js/node/_http_server.ts
- finish handlers fall back to the response's own socket when the stream
  destroyer has nulled req.socket (pipeline/compose cleanup) - without this
  the next kept-alive request failed with ERR_HTTP_SOCKET_ASSIGNED (seen in
  CI via next-auth and express suites)
- writeHead releases its exception scope on every exit path (the pairs-array
  and fetch-headers returns bypassed RELEASE_AND_RETURN, tripping exception
  scope validation on ASAN builds)
- raw header capture uses u32 length prefixes: maxHeaderSize is
  user-configurable past 64 KiB, so u16 could silently truncate and desync
  the buffer
- gmtime_r -> gmtime_s on Windows (gmtime_r is POSIX-only; broke all three
  Windows builds)
- releaseRenderedHeaders runs in finally at the write/_send/flushHeaders
  sites so a throwing writeHead cannot permanently mark the shared scratch
  array busy
- the native token scan treats '_' as a word character, matching the JS \W
  regexes it replaced
- drop the dead headersArray dispatcher parameter and its jsUndefined()
  append (dead since the dispatch bitfield)
- SAFETY comment on the raw-parts unsafe block (clippy
  undocumented_unsafe_blocks)
- document that the dispatch bitfield deliberately scans the parser's full
  header view while req.headers keeps the maxHeadersCount-truncated view
…aders enumerable

kKeepAliveIdleStart joins the other pre-declared per-socket fields so the
first response on a connection does not shape-transition the socket, and the
rawHeaders prototype accessor is enumerable like the own data property it
replaced (visible to for-in; own-property introspection still differs).
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/NodeHTTP.cpp Outdated
The finish-handler note still claimed res.socket has no .server (contradicted
by the destroyer-null fallback that reads it), and the header-capture doc
still described u16 length prefixes and the removed undefined slot.
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
Comment thread src/runtime/server/NodeHTTPResponse.rs
Comment thread src/runtime/server/NodeHTTPResponse.rs Outdated
…r fast path

- write_head_impl checks for a pending JS exception after the C++ header
  writer returns: inside writeHeadAndEnd there is no binding wrapper to
  observe it, so a throwing writeHead (header validation) would have let the
  end phase run with an exception pending (this was every remaining ASAN
  exception-validation failure - the install-test registries and express
  suites are node:http servers)
- onSocketTimeoutTimerExpired drops the fired timer's reference before
  _onTimeout, so the response-finish fast path re-arms instead of trusting a
  dead timer whose _idleTimeout still matches
- renderNativeHeaders computes the auto-header bits in locals and publishes
  to the module out-params only at return: String(value) can run user
  toString() that re-enters the renderer, and |= on shared slots would merge
  the nested render's bits
- writeHeadAndEnd uses the explicit this.get().deref() form so the inherent
  refcount deref is selected, matching cork()'s convention
- fix two doc comments still describing u16 length prefixes
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
…cover the http2 allowHTTP1 fallback

- NodeHTTPServer__writeHead now returns bool with the exception check done
  inside its own ThrowScope (RETURN_IF_EXCEPTION at every exit); the Rust
  side branches on the return value. Probing VM exception state from Rust
  via hasException tripped the exception-scope verifier itself: the FFI
  wrapper declares its own scope, and constructing it fires the validator
  before the read can clear the pending-check flag.
- the http2 allowHTTP1 HTTP/1 fallback handle implements writeHeadAndEnd
  (composing its writeHead + end), so res.end() on an HTTP/1.1 request to an
  allowHTTP1 server works again instead of throwing
- the fired-timer reference is only dropped for the keep-alive idle case;
  the server.timeout mid-request path keeps the fired timer in the slot so
  _unrefTimer()'s refresh() re-arms it, like net.Socket
Under JSC's exception-scope verification, a ThrowScope destructor simulates
a throw so its caller must perform an exception check. Native methods called
from JS return into a generated binding wrapper whose own scope does that
check; writeHeadAndEnd's write-head phase returns into Rust, which has no
scope, so the obligation survived until the next scope construction and the
verifier fired there (all the x64-asan unchecked-exception failures).

Two earlier attempts failed structurally: JSGlobalObject__hasException and
anything based on TopExceptionScope declare a verifying scope themselves, so
constructing them fires the verifier before the read can satisfy it.

The fix is a scope-free FFI that performs the same sanctioned read
RETURN_IF_EXCEPTION uses (vm.hasExceptionsAfterHandlingTraps()) - it
acknowledges the obligation without constructing a scope, and costs one
traps check in release builds. The write-head result still travels through
the bool return value.

Verified against a debug build with BUN_JSC_validateExceptionChecks=1: the
minimal repro (writeHead+end string/write/buffer shapes) and the previously
failing suites (node-http-backpressure, request-smuggling, express.json)
are clean under validation.
Comment thread src/runtime/server/NodeHTTPResponse.rs
…claration

The acknowledgeThrowScope extern was inserted between the comment and the
function it documents.
Comment thread src/js/node/http2.ts Outdated
cirospaciari and others added 2 commits July 10, 2026 06:25
renderNativeHeaders reports its Connection decision through the auto-header
bits instead of pushing header strings, so the fallback must consult them:
an explicit close (res.shouldKeepAlive = false, the graceful-shutdown
pattern) now wins over the parser-derived keep-alive flag, and the
bit-carried keep-alive timeout is used when present.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/_http_server.ts
…e the fallback's Keep-Alive line

- renderNativeHeaders can run user toString() while stringifying header
  values; a throw there previously left the scratch-array busy flag set for
  the process lifetime, forcing every later render onto fresh allocations.
  The render body now releases the flag before propagating.
- the http2 allowHTTP1 fallback tracks a user-sent Keep-Alive header in its
  header loop and suppresses the auto line, matching the native
  writeAutoHeaders instead of emitting a duplicate; the bit-carried timeout
  wins when present, and the handle's configured timeout is kept otherwise
  so the default path still advertises Keep-Alive.

@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.

All prior feedback has been addressed and this pass found nothing new — but given the scope (hot-path rewrite spanning C++/Rust/builtin JS, new FFI surface, several rounds of non-trivial fixes including the exception-scope handling), a human sign-off is warranted before merge.

Extended reasoning...

Overview

This PR reworks the node:http server hot path for throughput, stacked on #32488. It touches 8 files across three languages: C++ (NodeHTTP.cpp — new dispatch bitfield, flat-header capture, native auto-header rendering, a new scope-free exception acknowledgment FFI), Rust (NodeHTTPResponse.rs — new writeHeadAndEnd fused call, takeRawHeaders, raw-header storage, changed writeHead FFI signature), and builtin TypeScript (_http_server.ts, _http_incoming.ts, _http_outgoing.ts, http2.ts, internal/http.ts). It also adds two prototype methods to server.classes.ts.

Review history

Over ~8 review rounds I raised roughly 20 findings, including several 🔴-severity issues (u16 length-prefix truncation with large maxHeaderSize, req.socket === null after stream-destroyer breaking keep-alive, Windows gmtime_r build break, JSC exception-scope verifier failures on the fused writeHead+end path, missing writeHeadAndEnd on the http2 allowHTTP1 fallback, and a keep-alive timer re-arm regression). The author was responsive and every thread is now resolved with a fix commit; the current diff reflects all of them. The most recent two nits (duplicate Keep-Alive line in the http2 fallback, and scratchFlatHeadersBusy stuck on a throwing render) are fixed at HEAD (5663326). The bug-hunting pass on the current HEAD found nothing.

Security risks

No new external-input parsing surface beyond what the uWS parser already validates; the flat-header buffer bounds-checks on read; no auth/crypto changes. The main risk class here was memory/exception safety (JSC ThrowScope discipline across the new Rust→C++→Rust fused call, refcount balance on BackRef), which took three iterations to get right and now uses a bespoke acknowledgeThrowScope FFI — that mechanism in particular deserves a human look.

Level of scrutiny

High. This is production-critical hot-path code for every node:http server, with new cross-language FFI surface, module-level shared mutable state (scratchFlatHeaders, renderedAutoHeaders), a Node.js compat trade-off (rawHeaders own-property → prototype accessor), and a documented-but-intentional behavior divergence (maxHeadersCount vs. dispatch bitfield). It is also stacked on an unmerged base PR.

Other factors

The number and severity of issues found across review rounds — several of which would have shipped as user-visible regressions — indicates this change benefits from a maintainer's holistic pass on the design (particularly the exception-scope acknowledgment approach and the module-level scratch-state pattern), not just incremental bug review.

@cirospaciari

Copy link
Copy Markdown
Member Author

CI status on 5663326 (Build #71412): the only remaining failure after retries is grpc-js/test-tonic.test.ts on darwin-14-aarch64, failing with rustup could not choose a version of cargo - a runner toolchain misconfiguration hitting a long-standing main test (unrelated to this diff; it needs a default rustup toolchain on that fleet). Everything else is green, including all x64-asan shards - the exception-scope failures earlier in this PR's history are fully resolved (root cause and fix documented in the review threads).

@cirospaciari
cirospaciari merged commit 7b8e3e0 into claude/node-http-http2-compat Jul 10, 2026
78 checks passed
@cirospaciari
cirospaciari deleted the claude/http-server-hotpath-opts branch July 10, 2026 16:16
cirospaciari added a commit that referenced this pull request Jul 10, 2026
Stacked on #32488. Commits that recover the node:http server throughput
regression from the compat rewrite and push well past the previous
release.

## What this does

The compat rewrite in #32488 costs ~5% on a plain keep-alive
`http.createServer` workload versus v1.3.14. This PR removes per-request
work from the server hot path until the same workload is **+8.8% faster
than v1.3.14**, without changing behavior: the full vendored
`test/js/node/test/parallel/test-http-*` suite (376 files) passes
identically before and after every commit here.

## Numbers

`res.writeHead(200, {...}); res.end("Hello World")` under `oha -c 64`,
LTO release builds, server pinned to 2 cores, load generator on separate
cores, 7 reps interleaved round-robin across builds (so machine drift
hits every build equally), medians:

| build | req/s | vs 1.3.14 |
|---|---|---|
| bun 1.3.14 | 123,242 | — |
| #32488 as-is | ~116,500 | −4.9% |
| this PR | **134,033** | **+8.8%** |

The minimum rep of this PR (131,987) is above the maximum of every other
build. `Bun.serve`, WebSocket server/client, and the HTTP clients are
unaffected (measured flat).

## What changed

- **Lazy request headers + native dispatch bitfield**: the native side
no longer builds a 2N-string `rawHeaders` array per request. It captures
the raw header bytes on the response handle (flat buffer, stack-inline
up to 1 KiB) and computes an 8-bit bitfield for the headers the
dispatcher itself needs (connection close/upgrade tokens,
host/expect/content-length/transfer-encoding presence). `req.headers` /
`req.rawHeaders` materialize on first user access via
`handle.takeRawHeaders()`. A handler that never reads headers does zero
header materialization.
- **Native framework headers**: `Date`, `Connection: keep-alive/close`,
and `Keep-Alive: timeout=N` are no longer marshalled as JS strings; the
renderer passes two integers and the native side appends cached byte
blobs (the Date line rebuilt at most once per second; both keep-alive
lines as a single buffer write). Wire order and semantics
(sendDate=false, user-set Date/Connection, maxRequestsPerSocket)
unchanged.
- **`writeHeadAndEnd`**: one native call (and one native cork) for the
writeHead+end pair, replacing `cork(() => { writeHead(); end(); })` —
three crossings and a closure per response. Same `headersSent` semantics
on every error path.
- **Fewer native crossings**: reuse the already-read `flags` bitfield in
`end()` instead of separate `ended`/`aborted` getters; skip the
per-request `ondata`/`ondrain` clears when already cleared; skip
`takeRequestTrailers` for body-less requests; index `kOutHeaders`
directly in the trailer checks.
- **Per-request allocation removal**: reusable backing array in
`renderNativeHeaders` (consumed synchronously by native `writeHead`;
re-entrancy degrades to a fresh array), scratch options object for the
builtin `ServerResponse`, socket-cached bound abort handler, finish
listeners via `on()` instead of `once()`, hoisted `nextTick` callback.
- Assorted fixes found along the way: the `kUniqueHeaders` stamp
shape-transitioned every response even when the option was unset (`null`
passed an `!== undefined` guard); `_isLenientHeaderValidation` walked
`req.socket.server` twice per `setHeader`.

## How it's verified

- 376-file vendored Node http suite A/B against the base at every commit
boundary: zero regressions (flaky pairs re-run serially 5x to confirm).
- Exact wire-format probes: Date header format byte-for-byte, header
order on the wire, `sendDate=false`, explicit `Connection: close`, the
`maxRequestsPerSocket` Keep-Alive variant, exactly one Date line when
the user supplies their own.
- Behavior probes for the touchy paths: duplicate-header joining, wire
casing, post-teardown `req.headers` access, `Expect: 100-continue`,
chunked trailers, HEAD, pipelined requests, abort delivery.
- The lazy path preserves Node's duplicate-handling rules (first-wins vs
comma-joining per header) and `maxHeadersCount` truncation.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
cirospaciari added a commit that referenced this pull request Jul 14, 2026
Stacked on #32488. Commits that recover the node:http server throughput
regression from the compat rewrite and push well past the previous
release.

## What this does

The compat rewrite in #32488 costs ~5% on a plain keep-alive
`http.createServer` workload versus v1.3.14. This PR removes per-request
work from the server hot path until the same workload is **+8.8% faster
than v1.3.14**, without changing behavior: the full vendored
`test/js/node/test/parallel/test-http-*` suite (376 files) passes
identically before and after every commit here.

## Numbers

`res.writeHead(200, {...}); res.end("Hello World")` under `oha -c 64`,
LTO release builds, server pinned to 2 cores, load generator on separate
cores, 7 reps interleaved round-robin across builds (so machine drift
hits every build equally), medians:

| build | req/s | vs 1.3.14 |
|---|---|---|
| bun 1.3.14 | 123,242 | — |
| #32488 as-is | ~116,500 | −4.9% |
| this PR | **134,033** | **+8.8%** |

The minimum rep of this PR (131,987) is above the maximum of every other
build. `Bun.serve`, WebSocket server/client, and the HTTP clients are
unaffected (measured flat).

## What changed

- **Lazy request headers + native dispatch bitfield**: the native side
no longer builds a 2N-string `rawHeaders` array per request. It captures
the raw header bytes on the response handle (flat buffer, stack-inline
up to 1 KiB) and computes an 8-bit bitfield for the headers the
dispatcher itself needs (connection close/upgrade tokens,
host/expect/content-length/transfer-encoding presence). `req.headers` /
`req.rawHeaders` materialize on first user access via
`handle.takeRawHeaders()`. A handler that never reads headers does zero
header materialization.
- **Native framework headers**: `Date`, `Connection: keep-alive/close`,
and `Keep-Alive: timeout=N` are no longer marshalled as JS strings; the
renderer passes two integers and the native side appends cached byte
blobs (the Date line rebuilt at most once per second; both keep-alive
lines as a single buffer write). Wire order and semantics
(sendDate=false, user-set Date/Connection, maxRequestsPerSocket)
unchanged.
- **`writeHeadAndEnd`**: one native call (and one native cork) for the
writeHead+end pair, replacing `cork(() => { writeHead(); end(); })` —
three crossings and a closure per response. Same `headersSent` semantics
on every error path.
- **Fewer native crossings**: reuse the already-read `flags` bitfield in
`end()` instead of separate `ended`/`aborted` getters; skip the
per-request `ondata`/`ondrain` clears when already cleared; skip
`takeRequestTrailers` for body-less requests; index `kOutHeaders`
directly in the trailer checks.
- **Per-request allocation removal**: reusable backing array in
`renderNativeHeaders` (consumed synchronously by native `writeHead`;
re-entrancy degrades to a fresh array), scratch options object for the
builtin `ServerResponse`, socket-cached bound abort handler, finish
listeners via `on()` instead of `once()`, hoisted `nextTick` callback.
- Assorted fixes found along the way: the `kUniqueHeaders` stamp
shape-transitioned every response even when the option was unset (`null`
passed an `!== undefined` guard); `_isLenientHeaderValidation` walked
`req.socket.server` twice per `setHeader`.

## How it's verified

- 376-file vendored Node http suite A/B against the base at every commit
boundary: zero regressions (flaky pairs re-run serially 5x to confirm).
- Exact wire-format probes: Date header format byte-for-byte, header
order on the wire, `sendDate=false`, explicit `Connection: close`, the
`maxRequestsPerSocket` Keep-Alive variant, exactly one Date line when
the user supplies their own.
- Behavior probes for the touchy paths: duplicate-header joining, wire
casing, post-teardown `req.headers` access, `Expect: 100-continue`,
chunked trailers, HEAD, pipelined requests, abort delivery.
- The lazy path preserves Node's duplicate-handling rules (first-wins vs
comma-joining per header) and `maxHeadersCount` truncation.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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