Part 4 · 3 chapters · ~18 min

Modules, Loading and Bootstrap

Node's bootstrap from the C++ main to your first line, CommonJS (the wrapper, resolution, the cache and cycles), ESM (construct, link, instantiate, evaluate, top-level await), the dual package hazard and exports and imports maps, loader hooks with module.register, startup performance, and bundling and single executable applications.

14

Bootstrap and CommonJS

code
// every CommonJS file is wrapped in a function before it runs
(function (exports, require, module, __filename, __dirname) {
  // your file here
});

// require('x') resolution, simplified:
// 1. core module ('fs', 'node:fs')?                         → built in
// 2. starts with './', '../', '/'?                          → file, then file.js/.json/.node, then dir/index.js
// 3. otherwise walk up node_modules/ directories            → package.json "exports" (if present) or "main"
// result is cached in require.cache by absolute filename

// a cycle: a.js requires b.js, b.js requires a.js
// b sees a's exports object as it is at that moment: partially filled
console.log(require.resolve('lodash'), Object.keys(require.cache).length);
a production fact
Each require() on a cold path does several synchronous stat calls up the directory tree. A service that requires 3,000 files at boot pays that cost before it can serve. That is the "require waterfall" in part 4 chapter 4.
FROM main() TO YOUR FIRST LINE
what happens between typing node app.js and app.js running
node::StartC++ main()V8 platformthreads, isolatesnapshotdeserialise heapEnvironmentbindings, loopbootstrap JSlib/internal/bootstraprun_mainload app.js
swipe the figure sideways, or tap expand for full screen
1/5
C++ entry
The node binary starts in C++: parse options and NODE_OPTIONS, initialise OpenSSL and ICU, set up the V8 platform (worker threads for GC and compilation).
options, OpenSSL, ICU, V8 platformbefore any JavaScript exists
15

ESM, the dual package hazard and exports maps

code
// package.json for a package shipping both formats
{
  "name": "money",
  "type": "module",
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" },
    "./format": "./dist/format.js",
    "./package.json": "./package.json"
  },
  "imports": { "#internal/*": "./src/internal/*.js" }   // private aliases, only inside this package
}

The dual package hazard: if an app loads the ESM build through one path and the CJS build through another, it gets two copies of the module with two separate states. A singleton registry, a class used with instanceof, or a cache then silently splits in two. Mitigations: ship ESM only, or make the ESM entry a thin wrapper that re-exports the CJS build so there is one instance.

CommonJSESM
loadingsynchronous, at call timegraph built first, then evaluated; async
exportsa mutable object, copied on destructurelive bindings
top-level awaitnoyes
__dirnameyesimport.meta.dirname
interoprequire(esm) without TLA (Node 22.12+)import cjs gives default = module.exports, plus detected named exports
ESM: LINK, INSTANTIATE, EVALUATE
the module graph is built completely before any module body runs
constructfetch + parselinkresolve importsinstantiatebind live exportsevaluaterun bodies, post-order
swipe the figure sideways, or tap expand for full screen
1/5
construct
Starting at the entry, the loader resolves each specifier to a URL, reads the file and parses it, recursively, building the whole graph. No module code has run yet.
resolve, fetch, parse every module firstsyntax errors anywhere fail before anything runs
16

Loaders, startup performance and single executables

code
// customise resolution and loading: module.register (runs hooks off-thread)
// register.mjs
import { register } from 'node:module';
register('./hooks.mjs', import.meta.url);

// hooks.mjs: load .graphql files as modules exporting a string
export async function load(url, context, next) {
  if (url.endsWith('.graphql')) {
    const src = await (await import('node:fs/promises')).readFile(new URL(url), 'utf8');
    return { format: 'module', source: `export default ${JSON.stringify(src)};`, shortCircuit: true };
  }
  return next(url, context);
}
// node --import ./register.mjs app.mjs
startup leverwhat it saves
measure first: node --cpu-prof app.js, look at module loadingfinds the slow requires
lazy-require rarely used heavy modulesparse and execute time on boot
module.enableCompileCache() / NODE_COMPILE_CACHEV8 compilation on subsequent starts
bundling the server (esbuild) into one filethousands of file system lookups; good for serverless cold starts
a user-land startup snapshot (--build-snapshot)initialisation work, for CLIs and serverless

Single executable applications (SEA): Node can inject a bundled script into a copy of the node binary (--experimental-sea-config, then postject), producing one file to ship to machines without Node installed. Bundle first; SEA runs one script, not a node_modules tree.