codebytere

#52873: perf: boot every Node.js-hosting process from the Node snapshot / code cache

Merged
Created: Aug 16, 2026, 4:11:58 PM
Merged: Aug 17, 2026, 3:04:36 PM
4 comments
Target: main

Description of Change

Since #51703 the browser process boots from the embedded Node.js startup snapshot on native builds, but every other process that hosts Node.js - renderers with Node integration or an unsandboxed preload, Node-enabled web workers, the utility-process node service, and ELECTRON_RUN_AS_NODE / child_process.fork() children - still ran the full bootstrap, compiling the ~120-140 builtins it loads from source on every start (and then serializing a fresh code cache for each of them that nothing ever consumed: NODE_DEBUG_NATIVE=CODE_CACHE in a forked child prints 124× "Compiling … without code cache").

Three pieces:

  1. Build-time code cache for Node's own builtins in every process flavor. electron_natives_codecache now also emits a V8 code cache for Node's lib/ builtins (everything but internal/deps/*, which nothing loads during startup) for the flavors whose processes bootstrap Node from scratch, compiled against that flavor's isolate and flag set exactly like the electron/js2c/* bundles already were. The cache has to be in place before node::NewContext / node::CreateEnvironment run the bootstrap - i.e. before we can reach env->builtin_loader() - so it is installed as a process-wide default that every BuiltinLoader is seeded with (small new BuiltinLoader::SetProcessDefaultCodeCache() hook in the existing js2c code-cache node patch), from JavascriptEnvironment, NodeBindings::Initialize and the web-worker path. GN decides per flavor whether Node's builtins are embedded (always for the renderer flavor; utility/browser only on builds without a Node snapshot), so native builds carry one copy.
  2. ELECTRON_RUN_AS_NODE children and the utility-process node service create their isolate from the embedded Node snapshot too. Each has exactly one JavascriptEnvironment isolate (worker threads inherit the snapshot through IsolateData), so JavascriptEnvironment hands them the snapshot like it does for the browser process, CreateIsolateData gets its EmbedderSnapshotData, CreateEnvironment an empty context, the deserialized context is entered, and the duplicate per-isolate message listener the snapshot path already installed is skipped. Cross builds and custom-V8-snapshot setups keep the from-scratch path. The js2c cache flavor now follows what the isolate was actually created from (ELECTRON_RUN_AS_NODE children were previously fed the browser flavor, keyed to the Node-snapshot isolate, so even their node_init cache was silently rejected).
  3. A node_platform.cc fix that (2) exposed. WorkerThreadsTaskRunner::DelayedTaskScheduler drains its queue through a TaskQueue, which is a std::priority_queue treating plain v8::Tasks as equal, so a StopTask can run before an earlier-queued ScheduleTask; that then arms a timer on the stopped loop and NodePlatform::Shutdown() waits it out - observed as ~10 % of forked children stalling for exactly 8 s (V8's memory-reducer delay) at exit in release builds. New small patch drops ScheduleTasks that arrive after StopTask; I'll send it upstream as well.

Whether a process may use the Node snapshot is now decided only by comparing the blob it loaded against the built-in one (#52872), so the LoadBrowserProcessSpecificV8Snapshot fuse affects just the browser process, whose blob it swaps.

Tests: the DCHECK-only cache-status record now covers all builtins, and spec/api-js2c-code-cache-spec.ts asserts that browser / renderer / utility / run-as-node compile no Node builtin without consuming a cache entry, once as built and once from a copy of the app with the LoadBrowserProcessSpecificV8Snapshot fuse on. node-spec, utility-process, net, modules, asar, esm, context-bridge and ipc-renderer specs pass locally.

Linux x64 release build (official, ThinLTO, PGO), interleaved A/B against main, n=40 fresh processes per cell:

main this PR
ELECTRON_RUN_AS_NODE=1 electron -e 0 (spawn → exit) 82.5 ms 37.0 ms
child_process.fork(noop)'exit' 89.4 ms 41.1 ms
utilityProcess.fork() → first message 86.1 ms 39.7 ms
nodeIntegration renderer, timeOrigin → first preload statement 90.9 ms 64.7 ms
stripped binary +2.0 MB

(The +2.0 MB is the renderer flavor's cache for Node's builtins; a testing/dcheck build shows +6.4 MB because the caches serialize ~3.5× larger there.)

Checklist

  • PR description included and stakeholders cc'd
  • npm test passes
  • tests are changed or added

Release Notes

Notes: Improved startup time of utilityProcess.fork(), child_process.fork() / ELECTRON_RUN_AS_NODE child processes and Node.js-enabled renderers.

Backports

42-x-y
Pending
Waiting for a manual backport
43-x-y
Pending
Waiting for a manual backport
44-x-y
Pending
Waiting for a manual backport

Semver Impact

Major
Breaking changes
Minor
New features
Patch
Bug fixes
None
Docs, tests, etc.

Semantic Versioning helps users understand the impact of updates:

  • Major (X.y.z): Breaking changes that may require code modifications
  • Minor (x.Y.z): New features that maintain backward compatibility
  • Patch (x.y.Z): Bug fixes that don't change the API
  • None: Changes that don't affect using facing parts of Electron