Mission 01 · Node.js Internals Campaign

require() and import — a filesystem search, a cache keyed by path, and what runs before your code

Every part so far began 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. A bare specifier is a search of the directory tree, its result is cached under an absolute path, and almost everything surprising about module identity follows from those two facts.

14 sections9 figuresfive stepsresolve · cache · wrap · execute
§ 0 · THE SHORT ANSWER FIRST

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.

JAVASCRIPTFOUR LINES, HUNDREDS OF SYSCALLS
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 yet

The 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.

!Lock this in

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.

§ 1 · WHAT REQUIRE ACTUALLY DOES

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.

The five steps, in order
§1 · one require call
1 · resolve the specifier to a path
the search — the expensive step
kernel
Turns "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.
2 · check the cache
keyed by that resolved path
node
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.
3 · read the file
one open + read + close
kernel
Part 5's fs.readFile, synchronously, on the main thread. The loop is not running yet at startup, so nothing is being blocked that would otherwise progress.
4 · wrap the source in a function
the module wrapper
node
The file's text is placed inside a function taking exports, require, module, __filename and __dirname. §8 is about why, and why those five are not globals.
5 · call it
your code finally runs
main
The wrapper is invoked with a fresh module object. Whatever it leaves on module.exports is what the cache stores and every later require receives.
steps 3-5 happen exactly once per resolved path, ever
!Lock this in

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.

§ 2 · THE STRING DECIDES THE ALGORITHM

Relative, absolute, and bare

What Node does with your string depends entirely on its first characters, and the three cases have very different costs.

What each kind of specifier triggers
§2 · the three cases
"./util" or "../lib/util"
RELATIVE — resolved against __dirname
main
Joined to the requiring file's directory and then tried with extensions. Cheap and predictable: a handful of stats, no searching.
"/opt/app/util"
ABSOLUTE — used as given
main
Same extension probing, no directory walk at all. Rare in application code.
"lodash" or "express/lib/router"
BARE — triggers the node_modules walk
kernel
The expensive one. Node walks UP from the requiring directory, checking every node_modules on the way to the filesystem root. §3 counts what that costs.
"node:fs" or "fs"
BUILTIN — short-circuits everything
node
Matched against the list of built-in modules before anything touches the filesystem. Zero syscalls. The node: prefix makes it explicit and unshadowable.
only the third one searches

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.

§ 3 · WHY COLD START IS SLOW

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.

What one require costs, by specifier
§3 · the stat storm
the control is the one that fails — and it is the most expensive of all

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.

!Lock this in

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.

§ 4 · WHAT "./UTIL" IS ACTUALLY ASKING FOR

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.

BASHrequire("./util") — THE PROBE ORDER
./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.js

Each 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.

§ 5 · ONE FILE, ONE MODULE, FOREVER

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.

Four requires, and how many modules result
§5 · what the cache does
the control is the case where one package name gives you two modules

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.

§ 6 · TOP-LEVEL CODE RUNS ONCE

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.

JAVASCRIPTTHE ACCIDENTAL GLOBAL
// 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 1

That 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.

!Lock this in

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.

§ 7 · WHY IT RETURNS HALF AN OBJECT INSTEAD OF HANGING

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.

What each side actually receives
§7 · a cycle, stepped
nothing errors — the object is simply incomplete at the moment it is read

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.

§ 8 · WHERE MODULE, EXPORTS AND __DIRNAME COME FROM

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.

JAVASCRIPTWHAT NODE ACTUALLY EVALUATES
(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.

!Lock this in

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.

§ 9 · IMPORT IS NOT REQUIRE

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.

How a module graph is loaded
§9 · the three phases
1 · construction
parse, find every import, fetch recursively
kernel
The whole graph is discovered before any of it runs. Every import specifier must be statically analysable, which is exactly why you cannot import(variable) with the statement form.
2 · instantiation
allocate the bindings, link them up
node
Every exported name gets a slot, and every importer is pointed AT THAT SLOT — not given a copy of its value. This is what makes an ESM import a live binding.
3 · evaluation
run the bodies, deepest first
main
Only now does any code execute. By this point every name already exists, which is why a cycle in ESM behaves better than one in CommonJS.
CJS interleaves resolution and execution; ESM separates them completely

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.

§ 10 · THE DIFFERENCE THAT ACTUALLY BITES

CJS copies a value; ESM points at a slot

This is the semantic difference between the two systems, and it is not stylistic.

JAVASCRIPTTHE SAME MUTATION, TWO OUTCOMES
// 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 copy

The 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.

!Lock this in

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.

§ 11 · THE RULES FOR MIXING THEM

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.

What works, and why
§11 · the four combinations
ESM importing CommonJS
works — the whole exports becomes the default
node
Node evaluates the CJS module and hands you 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.
CommonJS requiring ESM
THROWS — ERR_REQUIRE_ESM
worker
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.)
CommonJS importing ESM dynamically
works — await import()
node
import() returns a promise, which gives the asynchronous phase somewhere to live. This is the escape hatch for the row above.
what decides which one a file is
.mjs / .cjs / package.json "type"
main
Extension wins; otherwise the nearest package.json decides, and the default is CommonJS. A file is one or the other before it is parsed.
the asymmetry is synchronous vs asynchronous, nothing more
§ 12 · THE MODELS WORTH CORRECTING

Ten claims about loading

These are the beliefs that produce the slow starts and the duplicate-instance bugs.

Which of these are true?
§12 · mental models
sort before you check — a guess you have committed to is worth more than a correction you read
!Lock this in

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.

§ 13 · THE FOUR SENTENCES WORTH KEEPING

If you remember nothing else

Loading is filesystem work, and it happens before the event loop (Part 4) has taken a single step.

What to carry away
§13 · the four sentences
require is a search, not a lookup
§0 · §3
kernel
A bare specifier walks up every directory to the filesystem root, probing candidates at each level. Failures cost more than successes.
the cache key is the resolved absolute path
§5
node
Two strings that resolve to one file give one module; one name resolving to two files gives two, with separate state and failing instanceof.
a module body runs exactly once
§6
main
Module scope is process-wide state you did not declare. With cluster it is per-worker, which is §6 of Part 11 arriving from the other side.
CJS copies values, ESM links bindings
§10
node
And CJS interleaves resolution with execution while ESM separates them, which is why one can be synchronous and the other cannot.
four sentences, one search

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.

Back to JIT-Atlas