Skip to content

http: avoid dictionary-mode objects in responses - #66420

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:http-fast-mode-objects
Open

mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:http-fast-mode-objects

Conversation

@mcollina

@mcollina mcollina commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

setHeader() stored headers in a { __proto__: null } literal and OutgoingMessage let EventEmitter create the same literal for its listener table. V8 creates null-prototype literals in dictionary mode, so every response paid for a hash table allocation on the first setHeader()/on() call, for runtime-miss stores (the profile shows StoreIC::Store, Factory::NewStoreHandler and TieringManager::NotifyICChanged in steady state) and for dictionary lookups on every header access, including the for...in in _storeHeader().

This PR uses a fast-mode object with an empty null-prototype chain for the headers (same keys, same enumeration, no inherited properties), and presets the listener table with the common events, as Readable/Writable already do.

Hello-world server (res.setHeader('Content-Type', ...) + res.end('Hello World')), server pinned to one core, wrk -t2 -c50 on two other physical cores, 4 interleaved rounds:

req/s user µs/req sys µs/req alloc B/req
main 40.9k 15.0 9.4 4374
this PR 50.0k 11.3 8.7 3905

The _events preset accounts for ~2% of that; the header container for the rest. With 8 request headers and a handler that reads two of them: 36.2k → 43.2k req/s. Responses are byte-identical.

How much the dictionary-mode container costs depends on the response pattern (same harness, 2 rounds each):

handler main this PR
setHeader('Content-Type') + end(body) 41.7–42.4k 51.3–51.8k
setHeader('Content-Type') + setHeader('Content-Length') + end(body) 47.1–49.6k 50.0–51.7k
writeHead(200, { ... }) + end(body) (no setHeader(), benchmark/fixtures "normal") 46.1–50.0k 50.2–50.9k

The existing benchmark/http fixtures either set Content-Length through setHeader() or pass every header to writeHead(), so they only show the smaller gains. The second commit adds a setHeaderImplicit case to http/set-header.js (res.setHeader('Content-Type', ...) + res.end(body), framing left to end()). benchmark/compare.js --runs 10 --filter set-header http:

                                                                        confidence improvement accuracy (*)   (**)   (***)
http/set-header.js duration=5 res='normal' benchmarker='wrk'                            2.53 %       ±4.03% ±5.61%  ±7.86%
http/set-header.js duration=5 res='setHeader' benchmarker='wrk'                 **      6.13 %       ±4.03% ±5.55%  ±7.61%
http/set-header.js duration=5 res='setHeaderImplicit' benchmarker='wrk'        ***     16.46 %       ±5.10% ±7.06%  ±9.78%
http/set-header.js duration=5 res='setHeaderWH' benchmarker='wrk'                       4.78 %       ±5.76% ±7.89% ±10.75%

simple.js, headers.js and incoming_headers.js are unchanged within noise.

—-

Ai generated, humanly reviewed.

`setHeader()` stored headers in a `{ __proto__: null }` literal and
`OutgoingMessage` let EventEmitter create the same literal for its
listener table. V8 creates null-prototype literals in dictionary mode,
so every response paid for a hash table allocation on the first
`setHeader()`/`on()` call and for dictionary lookups on every header
access, including the `for...in` in `_storeHeader()`.

Use a fast-mode object with an empty null-prototype chain for the
headers, and preset the listener table with the common events, as
streams already do.

Hello-world server (`res.setHeader()` + `res.end()`), one core:
40.9k -> 50.0k req/s (+22%); CPU 24.4 -> 20.0 us/req; 4374 -> 3905 B
of young-gen allocation per request. Responses are byte-identical.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
The existing `setHeader` and `setHeaderWH` cases set `Content-Length`
explicitly, and `normal` passes every header to `writeHead()`. The very
common handler shape `res.setHeader(...)` + `res.end(body)`, where
`end()` derives the framing, exercises a different path and was not
covered.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/http
  • @nodejs/net
  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added http Issues and PRs related to the http subsystem. needs-ci PRs that need a full CI run. labels Sep 30, 2026
@mcollina
mcollina marked this pull request as ready for review September 30, 2026 20:53
@codecov

codecov Bot commented Sep 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.38%. Comparing base (8bf7793) to head (3aacac4).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66420      +/-   ##
==========================================
- Coverage   90.39%   90.38%   -0.01%     
==========================================
  Files         792      792              
  Lines      275580   275704     +124     
  Branches    52840    52857      +17     
==========================================
+ Hits       249104   249193      +89     
- Misses      16897    16937      +40     
+ Partials     9579     9574       -5     
Files with missing lines Coverage Δ
lib/_http_outgoing.js 97.98% <100.00%> (+0.02%) ⬆️

... and 35 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.

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

Labels

http Issues and 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.

2 participants