node:http: server hot-path performance (stacked on #32488) - #33879
cirospaciari merged 20 commits into
Conversation
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.
|
Updated 2:44 AM PT - Jul 10th, 2026
❌ @cirospaciari, your commit 5663326 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33879That installs a local version of the PR into your bun-33879 --bun |
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.
…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).
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/js/node/_http_server.ts:3173-3180— Inwrite(),_send(), andflushHeaders(), ifhandle.writeHeadthrows (e.g.RangeErrorforstatusCodeoutside 100–999, orERR_INVALID_CHARon the status message),releaseRenderedHeaders(renderedHeaders)is never reached andscratchFlatHeadersBusystaystruefor the process lifetime — every laterrenderNativeHeaders()then permanently falls back toflat = [].end()already handles this with a catch-path release; consider wrapping thewriteHeadcall intry { ... } 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-levelscratchFlatHeadersBusy = trueand hands out the sharedscratchFlatHeadersarray; the caller is expected to callreleaseRenderedHeaders(flat)afterward, which flips the busy flag back tofalsewhenflat === scratchFlatHeaders.ServerResponse.prototype.end()(~line 3020) does this correctly on both the success and the catch path — it callsreleaseRenderedHeaders(renderedHeaders)before rethrowing. Butwrite()(~3173–3180),_send()(~3350), andflushHeaders()(~3461) callhandle.writeHead(..., renderedHeaders)followed directly byreleaseRenderedHeaders(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_rangethrowsRangeErrorwhenstatusCode < 100or> 999.ERR_INVALID_CHARwhenstatusMessagecontains a control byte.ERR_HTTP_HEADERS_SENT/ERR_STREAM_ALREADY_FINISHEDfrom the state gate.
For
write()and_send()the implicit-header path (headerStateSymbol === 'none') reacheshandle.writeHeadwiththis.statusCodeunvalidated in JS, sores.statusCode = 99; res.write('x')throws inside the cork closure afterrenderNativeHeadershas already set the busy flag.Why nothing else clears it
scratchFlatHeadersBusyis a module-levelletandreleaseRenderedHeadersis the only place that writesfalseto it. Once a throw skips that call, the flag is stuck for the process lifetime. Every subsequentrenderNativeHeaders()— for every response on the server — then takes theflat = []fresh-allocation branch, permanently defeating the reusable-array optimization. As a secondary effect,scratchFlatHeadersitself retains the failing request's rendered header strings indefinitely (the next call never runsscratchFlatHeaders.length = 0). The comment onscratchFlatHeaderssays 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, andend()'s explicit catch-path release suggests permanent degradation wasn't the intent.Step-by-step
- Client sends a request; handler does
res.statusCode = 99; res.write('x'). write()seesheaderStateSymbol !== sent, entershandle.cork(() => { ... }).renderNativeHeaders(this)runs:scratchFlatHeadersBusy = true, returnsscratchFlatHeaders.handle.writeHead(99, ...)reaches nativevalidate_integer_range, which throwsRangeError(min 100).- The exception propagates out of the cork closure;
releaseRenderedHeaders(renderedHeaders)on the next line never executes. scratchFlatHeadersBusyis nowtrueforever. Every future response'srenderNativeHeaders()sees it set and allocatesflat = []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 thehandle.writeHead(...)call intry { ... } finally { releaseRenderedHeaders(renderedHeaders) }at the three unguarded sites, matching whatend()already does. Not worth blocking on.
- 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).
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.
…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
…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.
…claration The acknowledgeThrowScope extern was inserted between the comment and the function it documents.
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.
…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.
There was a problem hiding this comment.
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.
|
CI status on 5663326 (Build #71412): the only remaining failure after retries is |
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>
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>
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.createServerworkload 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 vendoredtest/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")underoha -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: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
rawHeadersarray 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.rawHeadersmaterialize on first user access viahandle.takeRawHeaders(). A handler that never reads headers does zero header materialization.Date,Connection: keep-alive/close, andKeep-Alive: timeout=Nare 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, replacingcork(() => { writeHead(); end(); })— three crossings and a closure per response. SameheadersSentsemantics on every error path.flagsbitfield inend()instead of separateended/abortedgetters; skip the per-requestondata/ondrainclears when already cleared; skiptakeRequestTrailersfor body-less requests; indexkOutHeadersdirectly in the trailer checks.renderNativeHeaders(consumed synchronously by nativewriteHead; re-entrancy degrades to a fresh array), scratch options object for the builtinServerResponse, socket-cached bound abort handler, finish listeners viaon()instead ofonce(), hoistednextTickcallback.kUniqueHeadersstamp shape-transitioned every response even when the option was unset (nullpassed an!== undefinedguard);_isLenientHeaderValidationwalkedreq.socket.servertwice persetHeader.How it's verified
sendDate=false, explicitConnection: close, themaxRequestsPerSocketKeep-Alive variant, exactly one Date line when the user supplies their own.req.headersaccess,Expect: 100-continue, chunked trailers, HEAD, pipelined requests, abort delivery.maxHeadersCounttruncation.