lib: load fewer builtins when bootstrapping without a snapshot

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: https://github.com/nodejs/node/pull/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>
This commit is contained in:
Shelley Vohr authored and GitHub committed 2026-08-22 17:07:39 +00:00
1 parent f411275331
commit e38eede0e5
12 files changed
+78 -46

No files matched your search

+1 -2
View File
@@ -64,8 +64,6 @@ const { isArrayBufferView } = require('internal/util/types');
const binding = internalBinding('fs');
const { createBlobFromFilePath } = require('internal/blob');
const { Buffer } = require('buffer');
const { isBuffer: BufferIsBuffer } = Buffer;
const BufferToString = uncurryThis(Buffer.prototype.toString);
@@ -784,6 +782,7 @@ function openAsBlob(path, options = kEmptyObject) {
// To give ourselves flexibility to maybe return the Blob asynchronously,
// this API returns a Promise.
path = getValidatedPath(path);
const { createBlobFromFilePath } = require('internal/blob');
return PromiseResolve(createBlobFromFilePath(path, { type }));
}
@@ -292,12 +292,27 @@ rawMethods.resetStdioForTesting = function() {
// Needed by the module loader and generally needed everywhere.
require('fs');
require('util');
require('url'); // eslint-disable-line no-restricted-modules
internalBinding('module_wrap');
require('internal/modules/cjs/loader');
require('internal/modules/esm/loader');
require('internal/modules/esm/utils');
if (isBuildingSnapshot()) {
// Preloaded so that they are part of the snapshot, where they cost nothing
// at startup. When bootstrapping WITHOUT a snapshot (worker threads,
// embedders that create their own isolate, --no-node-snapshot) they are
// loaded on first use instead: the ESM loader (with its translators,
// resolver and their dependencies) by run_main/import(), the public util
// and url modules by whoever requires them, data: URL and TypeScript
// support by the module loaders, internal/blob by fs.openAsBlob(), and the
// DNS helpers by node:dns or an explicit --dns-result-order (see
// pre_execution).
require('util');
require('url'); // eslint-disable-line no-restricted-modules
require('internal/modules/esm/loader');
require('internal/data_url');
require('internal/modules/typescript');
require('internal/blob');
require('internal/dns/utils');
}
// Needed to refresh the time origin.
require('internal/perf/utils');
@@ -311,8 +326,6 @@ internalBinding('wasm_web_api');
internalBinding('worker');
// Needed by most execution modes.
require('internal/modules/run_main');
// Needed to refresh DNS configurations.
require('internal/dns/utils');
// Needed by almost all execution modes. It's fine to
// load them into the snapshot as long as we don't run
// any of the initialization.
+4 -4
View File
@@ -214,15 +214,15 @@ class ResolverBase {
}
let defaultResolver;
let dnsOrder;
// May already hold a value chosen by the snapshotted application; a
// --dns-result-order flag given at runtime overrides it in initializeDns().
let dnsOrder = 'verbatim';
const validDnsOrders = ['verbatim', 'ipv4first', 'ipv6first'];
const validFamilies = [0, 4, 6];
function initializeDns() {
const orderFromCLI = getOptionValue('--dns-result-order');
if (!orderFromCLI) {
dnsOrder ??= 'verbatim';
} else {
if (orderFromCLI) {
// Allow the deserialized application to override order from CLI.
validateOneOf(orderFromCLI, '--dns-result-order', validDnsOrders);
dnsOrder = orderFromCLI;
+2 -2
View File
@@ -180,7 +180,7 @@ const {
resolveWithHooks,
validateLoadStrict,
} = require('internal/modules/customization_hooks');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
const lazyTypeScript = getLazy(() => require('internal/modules/typescript'));
const packageJsonReader = require('internal/modules/package_json_reader');
const { getOptionValue, getEmbedderOptions } = require('internal/options');
const shouldReportRequiredModules = getLazy(() => process.env.WATCH_REPORT_DEPENDENCIES);
@@ -1888,7 +1888,7 @@ function wrapSafe(filename, content, cjsModuleInstance, format) {
Module.prototype._compile = function(content, filename, format) {
if (format === 'commonjs-typescript' || format === 'module-typescript' || format === 'typescript') {
this[kURL] ??= convertCJSFilenameToURL(filename);
content = stripTypeScriptModuleTypes(content, filename, this[kURL]);
content = lazyTypeScript().stripTypeScriptModuleTypes(content, filename, this[kURL]);
switch (format) {
case 'commonjs-typescript': {
format = 'commonjs';
+1 -3
View File
@@ -20,9 +20,6 @@ const {
ERR_UNSUPPORTED_ESM_URL_SCHEME,
} = require('internal/errors').codes;
const {
dataURLProcessor,
} = require('internal/data_url');
/**
* @param {URL} url URL to the module
@@ -40,6 +37,7 @@ function getSourceSync(url, context) {
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
source = fs.readFileSync(url);
} else if (protocol === 'data:') {
const { dataURLProcessor } = require('internal/data_url'); // Only for data: URLs.
const result = dataURLProcessor(url);
if (result === 'failure') {
throw new ERR_INVALID_URL(responseURL);
+4 -1
View File
@@ -30,7 +30,10 @@ const {
stripBOM,
urlToFilename,
} = require('internal/modules/helpers');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
function stripTypeScriptModuleTypes(source, url) {
// Only needed for TypeScript sources; keep it out of the loader's startup path.
return require('internal/modules/typescript').stripTypeScriptModuleTypes(source, url);
}
const {
kIsCachedByESMLoader,
Module: CJSModule,
+3 -1
View File
@@ -25,7 +25,9 @@ const {
kSourcePhase,
kEvaluationPhase,
} = internalBinding('module_wrap');
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
function stripTypeScriptModuleTypes(source, filename) {
return require('internal/modules/typescript').stripTypeScriptModuleTypes(source, filename);
}
const {
executionAsyncId,
+6 -1
View File
@@ -139,7 +139,12 @@ function prepareExecution(options) {
initializeConfigFileSupport();
require('internal/dns/utils').initializeDns();
// internal/dns/utils (and internal/net behind it) is only needed up front
// to validate an explicit --dns-result-order or to register the resolver's
// snapshot serialization; otherwise it is loaded with node:dns.
if (getOptionValue('--dns-result-order') || isBuildingSnapshot()) {
require('internal/dns/utils').initializeDns();
}
if (isMainThread) {
assert(internalBinding('worker').isMainThread);
+7 -3
View File
@@ -93,7 +93,11 @@ const {
kValidateObjectAllowObjects,
} = require('internal/validators');
const { percentDecode } = require('internal/data_url');
let percentDecode;
function lazyPercentDecode(input) {
percentDecode ??= require('internal/data_url').percentDecode;
return percentDecode(input);
}
const querystring = require('querystring');
@@ -1560,7 +1564,7 @@ function getPathBufferFromURLWin32(url) {
// percent encoded characters and we take the string as is. Any invalid
// percent encodings, e.g. `%ZZ` are ignored and are passed through
// literally.
const decodedu8 = percentDecode(Buffer.from(pathname, 'utf8'));
const decodedu8 = lazyPercentDecode(Buffer.from(pathname, 'utf8'));
const decodedPathname = Buffer.from(TypedArrayPrototypeGetBuffer(decodedu8),
TypedArrayPrototypeGetByteOffset(decodedu8),
TypedArrayPrototypeGetByteLength(decodedu8));
@@ -1635,7 +1639,7 @@ function getPathBufferFromURLPosix(url) {
// won't scan for the slashes at all, and instead will decode the bytes
// literally into the returned Buffer. We're going to do the best we can and
// just interpret the input url as a sequence of bytes.
const u8 = percentDecode(Buffer.from(pathname, 'utf8'));
const u8 = lazyPercentDecode(Buffer.from(pathname, 'utf8'));
return Buffer.from(TypedArrayPrototypeGetBuffer(u8),
TypedArrayPrototypeGetByteOffset(u8),
TypedArrayPrototypeGetByteLength(u8));
+2 -5
View File
@@ -30,10 +30,6 @@ const {
const EventEmitter = require('events');
const assert = require('internal/assert');
const path = require('path');
const {
internalEventLoopUtilization,
} = require('internal/perf/event_loop_utilization');
const errorCodes = require('internal/errors').codes;
const {
ERR_WORKER_NOT_RUNNING,
@@ -60,7 +56,6 @@ const {
WritableWorkerStdio,
} = workerIo;
const { createMainThreadPort, destroyMainThreadPort } = require('internal/worker/messaging');
const { deserializeError } = require('internal/error_serdes');
const { fileURLToPath, isURL, pathToFileURL } = require('internal/url');
const {
constructSharedArrayBuffer,
@@ -417,6 +412,7 @@ class Worker extends EventEmitter {
[kOnErrorMessage](serialized) {
// This is what is called for uncaught exceptions.
const { deserializeError } = require('internal/error_serdes');
const error = deserializeError(serialized);
this.emit('error', error);
}
@@ -699,6 +695,7 @@ function makeResourceLimits(float64arr) {
}
function eventLoopUtilization(util1, util2) {
const { internalEventLoopUtilization } = require('internal/perf/event_loop_utilization');
// TODO(trevnorris): Works to solve the thread-safe read/write issue of
// loopTime, but has the drawback that it can't be set until the event loop
// has had a chance to turn. So it will be impossible to read the ELU of
+9 -3
View File
@@ -1,5 +1,7 @@
'use strict';
const { defineLazyProperties } = require('internal/util');
const {
isInternalThread,
isMainThread,
@@ -30,8 +32,6 @@ const {
isMarkedAsUntransferable,
} = require('internal/buffer');
const { locks } = require('internal/locks');
module.exports = {
isInternalThread,
isMainThread,
@@ -53,5 +53,11 @@ module.exports = {
BroadcastChannel,
setEnvironmentData,
getEnvironmentData,
locks,
};
// The Web Locks API implementation is only needed once `locks` is used.
defineLazyProperties(
module.exports,
'internal/locks',
['locks'],
);
+21 -16
View File
@@ -20,10 +20,10 @@ const actual = {
// add more builtins to worker snapshots, we should also distinguish
// the two stages for them.
const expected = {};
const getFormatNativeModule = 'NativeModule internal/modules/esm/get_format';
expected.beforePreExec = new Set([
'Internal Binding builtins',
'Internal Binding encoding_binding',
'Internal Binding modules',
'Internal Binding errors',
'Internal Binding util',
@@ -88,10 +88,6 @@ expected.beforePreExec = new Set([
'NativeModule internal/v8/startup_snapshot',
'NativeModule internal/process/signal',
'Internal Binding fs',
'NativeModule internal/encoding',
'NativeModule internal/encoding/single-byte',
'NativeModule internal/encoding/util',
'NativeModule internal/blob',
'NativeModule internal/fs/utils',
'NativeModule fs',
'Internal Binding options',
@@ -109,15 +105,10 @@ expected.beforePreExec = new Set([
'Internal Binding diagnostics_channel',
'Internal Binding wasm_web_api',
'NativeModule internal/events/abort_listener',
'NativeModule internal/modules/typescript',
'NativeModule internal/data_url',
'NativeModule internal/mime',
'NativeModule internal/modules/esm/utils',
'Internal Binding worker',
'NativeModule internal/modules/run_main',
'NativeModule internal/net',
'NativeModule internal/dns/utils',
'NativeModule internal/modules/esm/get_format',
getFormatNativeModule,
'NativeModule internal/trace_events',
]);
@@ -126,8 +117,13 @@ expected.atRunTime = new Set([
]);
const { isMainThread } = require('worker_threads');
// Binaries built without the snapshot (e.g. cross-compiled) and
// --no-node-snapshot bootstrap the main context from scratch, like a worker.
const mainContextFromSnapshot = isMainThread &&
process.config.variables.node_use_node_snapshot &&
!process.execArgv.includes('--no-node-snapshot');
if (isMainThread) {
if (mainContextFromSnapshot) {
[
'Internal Binding cjs_lexer',
'NativeModule internal/modules/esm/assert',
@@ -138,15 +134,24 @@ if (isMainThread) {
'NativeModule internal/modules/esm/module_job',
'NativeModule internal/modules/esm/module_map',
'NativeModule url',
'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/typescript',
'NativeModule internal/net',
].forEach(expected.beforePreExec.add.bind(expected.beforePreExec));
} else if (isMainThread) {
expected.beforePreExec.delete(getFormatNativeModule);
expected.atRunTime.add(getFormatNativeModule);
} else { // Worker.
[
'Internal Binding locks',
'NativeModule diagnostics_channel',
'NativeModule internal/abort_controller',
'NativeModule internal/error_serdes',
'NativeModule internal/locks',
'NativeModule internal/perf/event_loop_utilization',
'NativeModule internal/process/worker_thread_only',
'NativeModule internal/streams/add-abort-signal',
'NativeModule internal/streams/compose',