require() is a filesystem search, not a lookup
Every part so far has started after your program was running. This one is about what happened before that — and the honest summary is that most of your cold start is spent asking the kernel whether files exist.
require("lodash") does not look anything up in a table. It performs a search: it walks up the directory tree from the file doing the requiring, and at every level it asks the filesystem whether a node_modules directory is there and whether it contains what you asked for. Each of those questions is a syscall.
const express = require("express"); // a search, then ~50 more searches
const lodash = require("lodash"); // its dependencies, and theirs
const app = express();
// none of your code has run yetThe second fact is the one that catches people: the result is cached, and the cache key is the resolved absolute path. Not the string you typed. Two different strings that resolve to one file give you one module; one string that resolves to two files gives you two, and they do not share state.
A module is not a namespace and not a file. It is a cache entry keyed by an absolute path, holding an object that was produced exactly once by running that file inside a function.
Resolve, check, load, wrap, execute
Every require call runs the same five steps in the same order, and only one of them involves your code.
"lodash" into /app/node_modules/lodash/lodash.js by walking directories and stat-ing candidates. This is where the syscalls are, and §3 is entirely about it.require.cache is an object keyed by absolute filename. A hit returns the existing module.exports immediately and steps 3 to 5 never happen — which is why the second require of anything is effectively free.fs.readFile, synchronously, on the main thread. The loop is not running yet at startup, so nothing is being blocked that would otherwise progress.exports, require, module, __filename and __dirname. §8 is about why, and why those five are not globals.module object. Whatever it leaves on module.exports is what the cache stores and every later require receives.The cache check sits at step 2, BEFORE the file is read. That single ordering is what makes modules singletons — and it is also what makes a circular require return a half-finished object instead of hanging forever.
Relative, absolute, and bare
What Node does with your string depends entirely on its first characters, and the three cases have very different costs.
node_modules on the way to the filesystem root. §3 counts what that costs.node: prefix makes it explicit and unshadowable.The node: prefix is worth adopting for exactly the reason the last row gives. Without it, a directory called node_modules/fs can shadow the built-in; with it, resolution is decided before the filesystem is consulted at all.
Every level, every time
A bare specifier makes Node walk up the tree. From /app/src/api/v2 it will try /app/src/api/v2/node_modules, then /app/src/api/node_modules, then /app/src/node_modules, then /app/node_modules, then /node_modules — and at each level it probes several filenames.
Multiply the bare case by the number of packages in your dependency tree, and by the fact that each of those packages requires its own dependencies from its own depth, and the arithmetic explains the startup time of a typical service better than anything in your code does.
A failed resolution is more expensive than a successful one, because success stops at the first hit and failure only stops at the filesystem root. The error you see is the cheapest part of it.
Five candidates for one specifier
A specifier without an extension is not a filename — it is a prefix. Node tries a fixed sequence of candidates and takes the first that exists.
./util # 1. exact, as a file
./util.js # 2. + .js
./util.json # 3. + .json
./util.node # 4. + .node (a compiled addon)
./util/ # 5. as a directory:
# a) package.json "main"
# b) ./util/index.jsEach of those is a stat(), and each miss is wasted. Writing the extension — require("./util.js") — skips the probing entirely, and in ESM it is mandatory for exactly this reason: the specification chose to make resolution cheap and unambiguous rather than convenient.
The string you typed is not the key
require.cache is an ordinary object whose keys are absolute filenames. Everything surprising about module identity follows from that one design choice.
The happy version is the singleton property everyone relies on: a config module required from forty files is read once, executed once, and hands the same object to all forty. The unhappy version is the control, and it is the cause of the instanceof failures and duplicated-state bugs that make people distrust their dependency tree.
Which makes module scope a global you did not declare
Because steps 3 to 5 happen exactly once per resolved path, anything at the top level of a module runs once for the lifetime of the process — and any variable there is shared by every consumer.
// counter.js
let count = 0; // runs ONCE, ever
module.exports = () => ++count;
// a.js
require("./counter")(); // 1
// b.js — a completely different file
require("./counter")(); // 2, not 1That is a feature when it is a connection pool and a bug when it is request state. It is also why Part 11 matters here: with cluster, that counter is per worker process, so four workers give you four independent counters and none of them is wrong from where it sits.
Module scope is process-wide state with no declaration and no obvious owner. If a module holds something mutable, decide deliberately whether it should be shared by the whole process — because it will be.
The cache entry is created before the file runs
If A requires B and B requires A, nothing deadlocks and nothing recurses forever. Node puts the module in the cache before executing it, so the second require gets a cache hit — on an object that is not finished yet.
The practical rule that falls out: in a cycle, reading at call time works and reading at load time does not. const { helper } = require("./a") at the top of a cyclic module captures whatever exists at that instant, which may be nothing; require("./a").helper() inside a function reads the finished object later.
Not globals — parameters
Node does not evaluate your file as a script. It concatenates it into a function and calls that function, which is why five identifiers exist in every module and in no browser.
(function (exports, require, module, __filename, __dirname) {
// ─── your file's entire source goes here ───
const fs = require("fs");
module.exports = { readIt };
});Three consequences follow immediately. Top-level var is function-scoped to the wrapper rather than global, so modules cannot collide by accident. this at the top level is module.exports, not the global object. And __dirname is a per-module value, which is what makes relative resolution work at all.
exports is a parameter holding a reference to module.exports. Assigning exports = {...} rebinds the parameter and exports nothing; assigning module.exports = {...} replaces the real thing. That is the whole of a bug people hit once and never forget.
The graph is built before anything executes
ESM does not resolve as it goes. It builds the entire dependency graph first, links the bindings, and only then evaluates — which is why import statements are hoisted, static, and cannot take a computed specifier.
import(variable) with the statement form.The separation is also why ESM can be asynchronous end to end, and why await works at the top level of a module: evaluation is already a distinct, schedulable phase rather than something happening inside a synchronous require call.
CJS copies a value; ESM points at a slot
This is the semantic difference between the two systems, and it is not stylistic.
// counter.mjs // counter.cjs
export let n = 0; let n = 0;
export const bump = () => ++n; module.exports = { n, bump: () => ++n };
// ESM consumer // CJS consumer
import { n, bump } from "./counter.mjs"; const { n, bump } = require("./counter.cjs");
bump(); bump();
console.log(n); // 1 <- live console.log(n); // 0 <- a copyThe CJS destructure took the value of n at require time and kept it. The ESM import refers to the exporting module's binding, so it sees the update. Neither is wrong; they are different contracts, and mixing them is where interop confusion comes from.
A CommonJS import is a snapshot of an object. An ESM import is a reference to a name in another module. If you have ever "fixed" a stale value by requiring inside the function instead of at the top, this is the mechanism you were working around.
One direction is easy; the other is not
The two systems coexist in one process, and the asymmetry between them follows directly from §9: ESM can wait, and CommonJS cannot.
module.exports as the default export. Named imports work when Node can statically detect them, which it does with a source scan and cannot always manage.require is synchronous by contract and ESM evaluation is asynchronous. There is nowhere to put the wait. (Newer Node can require a fully synchronous ESM graph, but the general case remains unavailable.)import() returns a promise, which gives the asynchronous phase somewhere to live. This is the escape hatch for the row above.package.json decides, and the default is CommonJS. A file is one or the other before it is parsed.Ten claims about loading
These are the beliefs that produce the slow starts and the duplicate-instance bugs.
Nearly every wrong take here assumes resolution is a lookup. It is a search of the filesystem, its result is cached under an absolute path, and both of those facts generate everything else on the list.
If you remember nothing else
Loading is filesystem work, and it happens before the event loop (Part 4) has taken a single step.
instanceof.That closes the loading story, and with it the ground the rest of the series stands on: Part 0 named the primitives; Parts 5, 8 and 9 will follow one call each through them, Part 4 opens the loop, Parts 10 and 11 buy second runtimes — and this part is everything that has to happen before any of it can start.