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

@anonrig anonrig 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:

stack requests per CPU-second p
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-bot nodejs-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
@anonrig anonrig 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 thread lib/_http_incoming.js Outdated
@jasnell jasnell added the semver-major PRs that contain breaking changes and should be released in the next major version. label Jul 8, 2026
@codecov

codecov Bot 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 lines Patch % Lines
lib/_http_outgoing.js 99.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 lines Coverage Δ
lib/_http_incoming.js 99.58% <100.00%> (+0.20%) ⬆️
lib/_http_server.js 96.77% <100.00%> (+0.27%) ⬆️
lib/_http_outgoing.js 97.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 anonrig force-pushed the http-server-fastpath branch from af61c79 to 43a9d71 Compare July 8, 2026 01:11
@jasnell jasnell removed the semver-major PRs that contain breaking changes and should be released in the next major version. label Jul 8, 2026
@anonrig

anonrig commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

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

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

Copy link
Copy Markdown
Collaborator

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants