Skip to content

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

Open
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot
Open

lib: load fewer builtins when bootstrapping without a snapshot#65329
codebytere wants to merge 1 commit into
nodejs:mainfrom
codebytere:perf/bootstrap-fewer-builtins-without-snapshot

Conversation

@codebytere

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.

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>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

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

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 16, 2026
@codebytere codebytere 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lib / src Issues and PRs related to general changes in the lib or src directory. request-ci Add this label to start a Jenkins CI on a PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants