lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

lib: load fewer builtins when bootstrapping without a snapshot - #65329

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Aug 22, 2026
Merged

lib: load fewer builtins when bootstrapping without a snapshot#65329
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

@codebyterecodebytere commented Aug 16, 2026

Copy link
Copy Markdown
Member

Worker startup gets ~6 % faster and a snapshot-less main-thread bootstrap (embedders that create their own isolate, --no-node-snapshot) ~10 % faster, by not eagerly loading builtins those paths never use. Startup with the snapshot is unchanged.

new Worker() → 'online' → terminate 21.3 → 20.1 ms *** +5.9 % ±1.4%
misc/startup-core.js mode='worker' script='semicolon.js' *** +5.4 % ±0.4%
misc/startup-core.js mode='worker' script='empty.mjs' *** +3.1 % ±0.3%
node --no-node-snapshot semicolon.js (start→exit) 58.9 → 53.4 ms *** +10.3 % ±1.1%
node --no-node-snapshot empty.mjs *** +2.5 % ±1.1%
misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Builtins compiled: bare worker 95 → 83; --no-node-snapshot empty CJS entry 76 → 59, empty ESM entry 76 → 69.

Contexts that aren't deserialized from the snapshot compile every builtin the bootstrap touches, so each eager require there is startup time (~0.15–0.4 ms apiece). Several are eager only so that they land in the snapshot, or for features the bootstrap doesn't use. This makes them lazy without changing the snapshot's contents:

  • is_main_thread.js: util, url, the ESM loader chain, internal/blob and internal/dns/utils are preloaded only if (isBuildingSnapshot()); otherwise they load on first use.
  • fsinternal/blob: only for fs.openAsBlob(). internal/urlinternal/data_url: only for the Buffer-returning file-URL helpers. execution, cjs loader, esm/translators, esm/load → TypeScript stripper / data: helpers: only for those inputs.
  • pre_executioninternal/dns/utils: only for an explicit --dns-result-order (still validated at startup) or a snapshot build; 'verbatim' becomes the variable's initializer, so a snapshot-time setDefaultResultOrder() still survives deserialization.
  • internal/worker → ELU / error_serdes on demand; worker_threads.locks via defineLazyProperties (as util does).

An intermediate version that didn't re-add these to the snapshot regressed node empty.mjs by 2–4 %, which is why the isBuildingSnapshot() block lists them explicitly. test-bootstrap-modules is adjusted for the worker-side list.

Tests:test-bootstrap-modules plus worker, url, fs, dns, process, cli, vm, snapshot, blob, esm, inspector, module, util, test-runner and single-executable suites pass.


Disclosure: the code, test, measurements and this description were written by Claude Code, directed and reviewed by @codebytere.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/loaders
  • @nodejs/net
  • @nodejs/startup
  • @nodejs/url

@nodejs-github-botnodejs-github-bot added lib / src Issues and PRs involving general changes in the lib/ or src/ directories. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codecov

codecovBot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (30bff4a) to head (0cc532a).
⚠️ Report is 105 commits behind head on main.

Files with missing linesPatch %Lines
lib/internal/bootstrap/switches/is_main_thread.js0.00%18 Missing ⚠️
lib/internal/url.js85.71%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #65329 +/- ##
==========================================
- Coverage 90.13% 90.09% -0.05% 
==========================================
Files 752 752 Lines 251568 252348 +780 Branches 47270 47462 +192 ==========================================
+ Hits 226759 227355 +596 - Misses 16168 16292 +124 - Partials 8641 8701 +60 
Files with missing linesCoverage Δ
lib/fs.js98.39% <100.00%> (+0.02%)⬆️
lib/internal/dns/utils.js100.00% <100.00%> (+0.55%)⬆️
lib/internal/modules/cjs/loader.js98.14% <100.00%> (ø)
lib/internal/modules/esm/load.js91.32% <100.00%> (-0.08%)⬇️
lib/internal/modules/esm/translators.js97.56% <100.00%> (+0.01%)⬆️
lib/internal/process/execution.js94.76% <100.00%> (+0.21%)⬆️
lib/internal/process/pre_execution.js97.75% <100.00%> (+0.60%)⬆️
lib/internal/worker.js96.75% <100.00%> (-0.02%)⬇️
lib/worker_threads.js100.00% <100.00%> (ø)
lib/internal/url.js93.28% <85.71%> (+0.01%)⬆️
... and 1 more

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

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

Copy link
Copy Markdown
Collaborator

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 5d49228 to 088f769CompareAugust 17, 2026 08:01
@targostargos added the needs-benchmark-ci PRs that need a benchmark CI run. label Aug 17, 2026
@github-actions

github-actionsBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (misc / startup-core): https://github.com/nodejs/node/actions/runs/32009241408

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

 confidence improvement accuracy (*) (**) (***)
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/empty.mjs' 0.95 % ±16.58% ±21.85% ±28.04%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/import-builtins.mjs' 0.60 % ±11.57% ±15.25% ±19.56%
misc/startup-core.js n=30 mode='process' script='benchmark/fixtures/require-builtins.js' 0.19 % ±13.51% ±17.81% ±22.85%
misc/startup-core.js n=30 mode='process' script='test/fixtures/semicolon.js' -0.02 % ±16.55% ±21.81% ±27.99%
misc/startup-core.js n=30 mode='process' script='test/fixtures/snapshot/typescript.js' -0.54 % ±8.97% ±11.82% ±15.17%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/empty.mjs' 3.74 % ±9.02% ±11.89% ±15.26%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/import-builtins.mjs' 1.08 % ±8.87% ±11.69% ±15.00%
misc/startup-core.js n=30 mode='worker' script='benchmark/fixtures/require-builtins.js' 1.37 % ±8.86% ±11.67% ±14.98%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/semicolon.js' 4.08 % ±9.20% ±12.13% ±15.56%
misc/startup-core.js n=30 mode='worker' script='test/fixtures/snapshot/typescript.js' -0.84 % ±8.49% ±11.19% ±14.36%
Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 10 comparisons, you can thus
expect the following amount of false-positive results:
0.50 false positives, when considering a 5% risk acceptance (*, **, ***),
0.10 false positives, when considering a 1% risk acceptance (**, ***),
0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

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

@codebyterecodebytere added request-ci Add this label to start a Jenkins CI on a PR. and removed needs-benchmark-ci PRs that need a benchmark CI run. labels Aug 17, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95

Copy link
Copy Markdown
Contributor

The modified test is failing on Windows, indicating this PR is adding flakiness. Can you have a look?

---
duration_ms: 293.997
exitcode: 1
severity: fail
stack: |-
node:internal/assert/utils:146
throw error;
^
AssertionError [ERR_ASSERTION]: These builtins are now no longer loaded before pre-execution.
If this is intentional, remove them from `expected.beforePreExec`.
--- These could be removed from expected.beforePreExec ---
[
'Internal Binding cjs_lexer',
'Internal Binding encoding_binding',
'NativeModule internal/blob',
'NativeModule internal/data_url',
'NativeModule internal/dns/utils',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/assert',
'NativeModule internal/modules/esm/get_format',
'NativeModule internal/modules/esm/load',
'NativeModule internal/modules/esm/loader',
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule internal/modules/esm/resolve',
'NativeModule internal/modules/esm/translators',
'NativeModule internal/modules/typescript',
'NativeModule internal/net',
'NativeModule url'
]
These builtins are now unexpectedly loaded at run time.
If this is intentional, add them to `expected.atRunTime`.
# Note: loading more builtins at run time can lead to startup performance regression.
- Consider lazy loading builtins that are not used universally.
--- These could be added to expected.atRunTime ---
[
'NativeModule internal/modules/esm/get_format'
]
12 !== 0
at Object.<anonymous> (d:\workspace\node-test-binary-windows-js-suites\node\test\parallel\test-bootstrap-modules.js:264:8)
at Module._compile (node:internal/modules/cjs/loader:1937:14)
at Module._extensions..js (node:internal/modules/cjs/loader:2077:10)
at Module.load (node:internal/modules/cjs/loader:1659:32)
at Module._load (node:internal/modules/cjs/loader:1451:12)
at wrapModuleLoad (node:internal/modules/cjs/loader:261:19)
at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:154:5)
at node:internal/main/run_main_module:33:47 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: 12,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v27.0.0-pre
...

https://ci.nodejs.org/job/node-test-binary-windows-js-suites/42385/RUN_SUBSET=2,nodes=win11-arm64-COMPILED_BY-vs2022_clang-arm64/testReport/junit/(root)/parallel/test_bootstrap_modules/

@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 088f769 to 0d3b8a7CompareAugust 18, 2026 09:18
@nodejsnodejs deleted a comment from codebytere-antAug 18, 2026
@codebytere

codebytere commented Aug 18, 2026

Copy link
Copy Markdown
MemberAuthor

@aduh95 fixed!

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from 0d3b8a7 to f95b3c7CompareAugust 18, 2026 14:13
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 19, 2026
@github-actionsgithub-actionsBot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@mcollina mind re-reviewing?

Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Comment threadtest/parallel/test-bootstrap-modules.js Outdated
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
@codebytere
codebytereforce-pushed the perf/bootstrap-fewer-builtins-without-snapshot branch from f95b3c7 to 0cc532aCompareAugust 20, 2026 12:39
@codebyterecodebytere added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 20, 2026
@joyeecheung

Copy link
Copy Markdown
Member

I am in favor of "for snapshot less mode, try not to eager load that much" in general, although some of the removals seem somewhat too conservative (e.g. the url and util modules are commonly used by most applications) and may only favor the startup of empty scripts but penalise the startup of a real application that actually does something. So I think we should check the performance impact of the scripts that does something beyond being empty as well. If the number

misc/startup-core.js mode='process' (snapshot), all scripts ±0.5 % n.s.

Comes from that scenario then LGTM, otherwise can you run the benchmark across the startup-core and startup-cli-version data sets?

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

Copy link
Copy Markdown
Collaborator

@codebytere

Copy link
Copy Markdown
MemberAuthor

@joyeecheung yes, that row is startup-core mode=process with the snapshot, all five scripts including require-builtins.js and import-builtins.mjs; the benchmark CI run above (https://github.com/nodejs/node/actions/runs/32009241408) shows the same on all four machines: process rows within ±1 % n.s., worker rows +1…5 %.

for the "app that actually does something" case i ran startup-cli-version and a builtin-heavy entry point both with the snapshot and with --no-node-snapshot (n=30, linux x64):

 with snapshot --no-node-snapshot
startup-cli-version eslint --version -0.14 % n.s. +1.70 % ***
npm --version -0.00 % n.s. +0.69 % ***
npx --version +0.11 % n.s. +0.80 % **
corepack --version -0.19 % n.s. +3.21 % ***
require-builtins.js (start -> exit) -0.08 % n.s. +1.97 % ***
import-builtins.mjs +0.19 % n.s. +0.13 % n.s.
empty CJS entry +0.04 % n.s. +10.59 % ***

so nothing gets penalized: with the snapshot the preloaded set is unchanged (they sit behind isBuildingSnapshot() rather than being removed), and without it util/url/the esm loader are loaded on first require at the same cost as before, just not up front for entry points that never touch them. the win shrinks as the app uses more of core, as you'd expect, but it doesn't go negative.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codebyterecodebytere added the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
@nodejs-github-bot
nodejs-github-bot merged commit e38eede into nodejs:mainAug 22, 2026
71 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in e38eede

@nodejs-github-botnodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Aug 22, 2026
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
aduh95 pushed a commit that referenced this pull request Aug 25, 2026
Contexts that are not deserialized from the built-in snapshot -- worker
threads, and the main context of embedders that create their own
isolate or of `node --no-node-snapshot` -- compile (with the code cache
at best) every builtin the bootstrap touches, so each eagerly required
builtin is startup time (~0.15-0.4 ms apiece). A number of them are only
required eagerly so that they end up in the snapshot, or for features
the bootstrap path never uses.
Load lazily what those paths do not need:
- is_main_thread.js: preload util, url, the ESM loader (translators,
resolver, module_job/map, source maps, node:module, vm modules, mime,
data_url, the TypeScript stripper), internal/blob and
internal/dns/utils only while building a snapshot; they load on first
use otherwise.
- fs: internal/blob (+ internal/encoding and its tables) is only used
by fs.openAsBlob().
- internal/url: internal/data_url (+ internal/mime) is only used by the
Buffer-returning file URL helpers.
- internal/process/execution, the CommonJS loader, esm/translators and
esm/load: the TypeScript stripper and data: URL helpers are only
needed for TypeScript sources / data: URLs.
- pre_execution: internal/dns/utils (+ internal/net) is only needed up
front to validate an explicit --dns-result-order or to register the
resolver's snapshot serializer; the default order becomes the
variable's initializer.
- internal/worker: event_loop_utilization and error_serdes are only
needed once a sub-worker's ELU is read or it reports an error.
- worker_threads: `locks` is defined lazily, like util's lazy exports.
Main-thread startup with the snapshot is unchanged (the same modules
are preloaded into it; the bootstrap-modules test lists are adjusted).
A bare worker compiles 95 -> 83 builtins (cold start -5%); without the
snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an
empty ES module entry point 76 -> 69.
Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com>
PR-URL: #65329
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
@panvapanva mentioned this pull request Aug 26, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / srcIssues and PRs involving general changes in the lib/ or src/ directories.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants

@codebytere@nodejs-github-bot@aduh95@joyeecheung@mcollina@jasnell@legendecas@avivkeller@targos