Skip to content

http: reduce per-request server overhead - #64348

Open
anonrig wants to merge 2 commits into
nodejs:mainfrom
anonrig:http-server-fastpath
Open

http: reduce per-request server overhead#64348
anonrig wants to merge 2 commits into
nodejs:mainfrom
anonrig:http-server-fastpath

Conversation

@anonrig

@anonriganonrig commented Jul 7, 2026

Copy link
Copy Markdown
Member

Server-side fast-path work reducing per-request overhead on the hot
keep-alive path. Builds on #64346 (its commit is the base of this branch and
appears in the diff until it lands; the two commits on top are this PR).

What changes

http: reduce per-request server overhead

  • end(chunk) issued a second, empty socket.write whose only purpose was
    to carry the finish callback — a full Writable pass and an extra
    cork-queue entry per response. When the header block is already rendered
    and the framing is final (no chunked trailer, no strict content-length
    accounting), the finish callback now rides the body write; writes
    complete in order, so observable finish timing is unchanged.
  • The response options bag and the pending-data callback only depend on the
    connection: allocated once per socket, reused per response.
  • The response close listener is installed once per socket (the handler was
    already guarded on socket._httpMessage), instead of an
    add/removeListener pair per response.
  • Status lines for default reason phrases are cached per status code; the
    Keep-Alive: line is memoized per (timeout, max) configuration.

http: move invariant IncomingMessage defaults to the prototype
dropped from this PR after review (semver-major per @jasnell); will be
resubmitted separately with the proper label.

Measurements

CPU-per-request harness (fresh server process per sample, in-process
pipelined keep-alive clients, interleaved old/new, Welch t-test), hello
world with Content-Length, baseline = #64346's head:

stackrequests per CPU-secondp
first commit+7.34% (25x600k samples)2.0e-14
both commits+8.46% (15x600k samples)2.2e-11

Cross-check under autocannon (-c 50): 130.5K -> 138.9K req/s (+6.4%)
for the first commit alone. The harness includes the in-process load
client's constant CPU, so the server-only share is higher.

Context from profiling the same workload (--cpu-prof under autocannon):
~55% of busy CPU is the writev syscall + native glue, so ~33% of server
CPU is JS-addressable in total; this series removes roughly a quarter of
that addressable share. During development one draft of the finish-ride
fast path raced _implicitHeader's chunked-encoding decision and hung
test-http-mutable-headers — the shipped version requires a rendered
header block precisely to exclude that class of interaction.

Correctness

  • All test/parallel/test-http-*, test-http.js, test-https-*, and
    test-http2-* pass; linters clean.
  • Not included on purpose: defaulting optimizeEmptyRequests (documented
    as breaking for servers that read bodies of body-header-less requests —
    measured at an additional +4.4% for opt-in users) and any change to
    drain/backpressure semantics.

AI assistance disclosure

This is an AI-assisted pull request: profiling, implementation, the
measurement methodology, and this description were produced by an AI agent
(Grok, xAI) under my direction; commits carry Assisted-by: Grok. I
reviewed and take responsibility for every change.

anonrig added 2 commits July 7, 2026 18:08
Cache the lenient-header-validation decision per message instead of
re-deriving it through the six-branch req/socket/server walk on every
setHeader/appendHeader call (and per addTrailers key); the inputs are
fixed once the message is constructed. Pre-filter the known-field
matcher on (length, first character) so ordinary headers skip the
per-header toLowerCase() allocation; the filter only rejects names that
cannot match. Load the `socket` prototype accessor once per body write
instead of three times, hoist the repeated _header/_keepAliveTimeout/
_maxRequestsPerSocket/_contentLength/headers.length loads the engine
cannot fold across calls, and flush corked chunked buffers through a
shared callback runner instead of allocating a closure per flush.
The hoists mirror what Bun's fork of these files carries on top of the
shared lineage (bun/src/js/node/_http_outgoing.ts: write_ msgSocket,
_send header local, _storeHeader/processHeader length locals,
runChunkCallbacks, flat one-shot setHeader validation).
Mechanism benchmarks driving the shipped classes through the public API
(fresh process per sample, 30 interleaved samples per binary, Welch
t-test): flushHeaders with five headers +8.25% (p=6.2e-11), a 24-header
response +4.26% (p=6.7e-7), tight setHeader loop +2.64% (p=2.0e-3);
sign-stable in an independent 15-sample repeat. No statistically
significant regression across the captured benchmark/http suite
(headers, incoming_headers, chunked, end-vs-write-end,
client-request-body, create-clientrequest, check_*).
The new test locks the matcher semantics: known fields keep being
recognized in any casing, and unknown names sharing a known field's
length and first letter are emitted verbatim.
Refs: https://github.com/oven-sh/bun/blob/main/src/js/node/_http_outgoing.ts
Assisted-by: Grok
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Eliminate per-request work that is invariant or redundant on the server
response path:
- end(chunk) issued a second, empty socket.write whose only purpose was
to carry the finish callback, paying a full Writable pass and an
extra cork-queue entry per response. When the header block is already
rendered and the framing is final (no chunked trailer, no strict
content-length accounting), the finish callback rides the body write
itself; writes complete in order, so the observable finish timing is
unchanged.
- The response options bag and the pending-data callback only depend on
the connection, not the request: allocate them once per socket and
reuse them for every response on a keep-alive connection.
- The response close listener is a no-op unless a response is attached
(socket._httpMessage guard), so it is installed once per socket
instead of paying the add/removeListener pair per response.
- Status lines for default reason phrases are cached per status code,
and the Keep-Alive header line is memoized per (timeout, max)
configuration.
Measured with a CPU-per-request harness (fresh process per sample,
25x600k-request interleaved samples, Welch t-test) on a keep-alive
hello-world server: +7.34% requests per CPU-second (p=2.0e-14), and
about +6.4% requests/sec under autocannon. All test/parallel http and
https tests pass.
Assisted-by: Grok
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net

@nodejs-github-botnodejs-github-bot added http Issues or PRs related to the http subsystem. needs-ci PRs that need a full CI run. labels Jul 7, 2026
@anonriganonrig added request-ci Add this label to start a Jenkins CI on a PR. commit-queue-rebase Add this label to allow the Commit Queue to land a PR in several commits. labels Jul 7, 2026
Comment threadlib/_http_incoming.js Outdated
@jasnelljasnell added the semver-major PRs that contain breaking changes and should be released in the next major version. label Jul 8, 2026
@codecov

codecovBot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.45055% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.25%. Comparing base (4ee2117) to head (af61c79).

Files with missing linesPatch %Lines
lib/_http_outgoing.js99.21%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #64348 +/- ##
==========================================
+ Coverage 90.23% 90.25% +0.01% 
==========================================
Files 741 741 Lines 240979 241095 +116 Branches 45401 45417 +16 ==========================================
+ Hits 217449 217594 +145 + Misses 15112 15064 -48 - Partials 8418 8437 +19 
Files with missing linesCoverage Δ
lib/_http_incoming.js99.58% <100.00%> (+0.20%)⬆️
lib/_http_server.js96.77% <100.00%> (+0.27%)⬆️
lib/_http_outgoing.js97.77% <99.21%> (+0.14%)⬆️

... and 26 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@anonrig
anonrigforce-pushed the http-server-fastpath branch from af61c79 to 43a9d71CompareJuly 8, 2026 01:11
@jasnelljasnell removed the semver-major PRs that contain breaking changes and should be released in the next major version. label Jul 8, 2026
@anonrig

Copy link
Copy Markdown
MemberAuthor

cc @jasnell@mcollina@ronag can i get a review on this please?

@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jul 9, 2026
@nodejs-github-bot

This comment was marked as outdated.

@gurgundaygurgunday left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina

Copy link
Copy Markdown
Member

linter is not happy

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@Ethan-ArrowoodEthan-Arrowood 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.

lgtm. Also TIL about ~~ integer coercion.

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

Labels

commit-queue-rebaseAdd this label to allow the Commit Queue to land a PR in several commits.httpIssues or PRs related to the http subsystem.needs-ciPRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants

@anonrig@nodejs-github-bot@mcollina@jasnell@ronag@metcoder95@Ethan-Arrowood@gurgunday