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.
Bootstrap and CommonJS
// 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);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.ESM, the dual package hazard and exports maps
// 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.
| CommonJS | ESM | |
|---|---|---|
| loading | synchronous, at call time | graph built first, then evaluated; async |
| exports | a mutable object, copied on destructure | live bindings |
| top-level await | no | yes |
__dirname | yes | import.meta.dirname |
| interop | require(esm) without TLA (Node 22.12+) | import cjs gives default = module.exports, plus detected named exports |
Loaders, startup performance and single executables
// 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 lever | what it saves |
|---|---|
measure first: node --cpu-prof app.js, look at module loading | finds the slow requires |
| lazy-require rarely used heavy modules | parse and execute time on boot |
module.enableCompileCache() / NODE_COMPILE_CACHE | V8 compilation on subsequent starts |
| bundling the server (esbuild) into one file | thousands 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.