When we write require("express"), how does Node actually find that file? The resolution algorithm is well-defined and worth knowing — it explains a lot of bugs (“why is it picking up the wrong version?”, “why does my monorepo break?”).
The big picture
In simple language, Node walks through a checklist for require(X):
- Is
Xa core module (fs,http,path, …)? Use that. - Does
Xstart with./,/, or../? Treat it as a file or directory path. - Otherwise, walk node_modules up the directory tree until found.
If none of these work, we get the famous Error: Cannot find module 'X'.
Core modules win first
If X matches a built-in name (fs, path, crypto, http, etc.), Node returns the built-in regardless of anything in node_modules. Since Node 16 we can prefix with node: to be explicit and immune to userland shadowing:
const fs = require("node:fs"); // always the built-in
Relative and absolute paths — LOAD_AS_FILE then LOAD_AS_DIRECTORY
For require("./utils"), Node tries in this order:
./utils exact path (if a file)
./utils.js
./utils.json
./utils.node (compiled C++ addon)
./utils/package.json read "main" field
./utils/index.js
./utils/index.json
./utils/index.node
This is why require("./utils") works when the file is utils.js — Node appends extensions for us.
node_modules tree walk
For a bare specifier like require("express"), Node walks up the directory tree, checking for node_modules/express at each level until it hits the filesystem root:
/Users/me/project/api/src/routes/users.js ← calling from here
Checks:
/Users/me/project/api/src/routes/node_modules/express
/Users/me/project/api/src/node_modules/express
/Users/me/project/api/node_modules/express
/Users/me/project/node_modules/express ← found! use this
/Users/me/node_modules/express
/Users/node_modules/express
/node_modules/express
This is why monorepos work — packages at the root resolve from any subfolder. And it’s why a deeply nested duplicate of a package can shadow the root one.
Loading a node_modules package
Once Node finds node_modules/express/, it needs to pick an entry file. It reads package.json:
- If
exportsfield exists → use that (conditional exports, see below). - Else if
mainfield exists → use that file. - Else fall back to
index.js.
{
"name": "express",
"main": "./index.js"
}
The exports field changes things
Modern packages use exports, which is strict. It blocks access to internal files and supports conditions (import vs require, node vs browser).
{
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.js"
},
"./utils": "./dist/utils.js"
}
}
With exports, require("my-pkg/internal/private") throws — even if the file exists. This is module encapsulation.
Caching — modules load once
Node caches the resolved module by its absolute path. The second require("express") returns the same exports object as the first. The cache lives at require.cache.
console.log(require.cache);
// { '/abs/path/index.js': Module { ... } }
delete require.cache[require.resolve("./config")]; // force reload
const fresh = require("./config");
This is why a module’s top-level code runs once per process — not once per import.
Inspecting resolution
require.resolve() returns the resolved path without loading the module. Super useful when debugging “wait, which copy is it picking up?”:
console.log(require.resolve("express"));
// /Users/me/project/node_modules/express/index.js
To run with NODE_PATH extra search dirs, set the env var (rare, mostly used for global tools):
NODE_PATH=/usr/local/lib/node_modules node script.js
Common gotchas
- Wrong version in a monorepo — a workspace’s own
node_modulesshadows a hoisted version. Runrequire.resolveto confirm. - Case sensitivity — works on macOS (case-insensitive FS), breaks on Linux. Always match casing exactly.
- Symlinks — by default Node resolves to the real path. Use
--preserve-symlinksfor some monorepo setups.