Module Resolution and node_modules
Every time your code calls require("something"), Node.js has to turn that string into an exact file on disk before it can run a single line of it. That lookup is not magic — it follows a precise, documented algorithm that checks whether the specifier is a built-in module, a relative or absolute file path, or the name of an installed package. Understanding this algorithm is what lets you fix confusing Cannot find module errors, understand why npm install sometimes produces nested node_modules folders, and correctly structure the entry points of your own packages.
Overview: How Node.js Finds a Module
When you call require(specifier) inside a CommonJS file, Node classifies the specifier into one of three categories before it ever touches the filesystem:
- Core module — a name like
fs,path, ornode:httpthat ships inside the Node.js binary itself. Core modules resolve instantly and always take priority: nothing innode_modulescan shadow them by using the exact same name (this is even more explicit with thenode:prefix, which can only mean the built-in). - File module — a specifier starting with
./,../, or/. Node treats these as literal filesystem paths, relative to the file doing the requiring (never relative to the current working directory). - Package module — anything else, such as
expressorlodash/fp. Node assumes this lives inside anode_modulesdirectory and searches for it using the algorithm described below.
For file and package modules, Node also has to decide which file inside a matched location to actually load, because require("./utils") does not tell it whether utils is a file or a directory. The resolver tries, in order: the exact path; the path with .js, .json, then .node (native addon) appended; and finally, if the path is a directory, that directory’s package.json "main" field (falling back to index.js, index.json, or index.node if there is no package.json or no "main" field). The first match wins, and the search stops there.
The node_modules walk-up
For a bare package specifier, Node does not look in one fixed place. It builds a list of candidate directories by starting at the requiring file’s own folder and walking upward: ./node_modules, then ../node_modules, then ../../node_modules, and so on, all the way to the filesystem root. At each level it checks whether a node_modules folder exists there and contains the requested package; the first hit wins. This is exactly why a package installed once at your project root is visible from every file inside that project, no matter how deeply nested — every subdirectory’s search path eventually reaches the root node_modules.
It’s also why npm sometimes creates nested node_modules folders: if two packages in your dependency tree need incompatible major versions of the same dependency, npm cannot flatten both into the top-level folder. Instead it installs one version at the root and nests a second copy inside the dependent package’s own node_modules directory, so that package’s internal require calls find the version it actually needs first.
The package.json “exports” field
Modern packages can add an "exports" field to package.json to explicitly declare their public entry points. When "exports" is present, it takes priority over "main" and it encapsulates the package: only the subpaths listed in the map can be required or imported. Reaching into an unlisted internal file (e.g. require("some-lib/lib/internal.js")) throws ERR_PACKAGE_PATH_NOT_EXPORTED, even if that file physically exists on disk. This is a deliberate encapsulation mechanism, and it is a common source of upgrade breakage when people previously relied on deep-importing a library’s internals.
Syntax
const mod = require("specifier");
const { name } = require("specifier");
const resolvedPath = require.resolve("specifier");
| Specifier form | Example | Resolved as |
|---|---|---|
| Core module | require("node:path") |
Built into the Node binary; no filesystem lookup |
| Relative file | require("./utils") |
File relative to the current file’s directory |
| Absolute file | require("/etc/config.js") |
Exact filesystem path |
| Package | require("express") |
Walks up node_modules directories |
| Package subpath | require("express/lib/router") |
Package directory found first, then subpath resolved inside it (subject to "exports") |
Examples
Example 1: Resolving a relative file module
Save this as lib/greet.js:
function greet(name) {
return `Hello, ${name}!`;
}
module.exports = { greet };
And this as app.js in the parent folder:
const { greet } = require("./lib/greet");
console.log(greet("World"));
Output:
Hello, World!
Because the specifier starts with ./, Node never touches node_modules at all. It resolves ./lib/greet relative to app.js‘s own directory, fails to find an exact file named greet, then succeeds on greet.js.
Example 2: Inspecting the search path with module.paths
Every CommonJS module has a module.paths array listing the exact directories Node will check, in order, for a bare package specifier. Suppose this file is saved as /home/node/app/scripts/paths.js:
console.log(module.paths);
Output:
[
'/home/node/app/scripts/node_modules',
'/home/node/app/node_modules',
'/home/node/node_modules',
'/home/node_modules',
'/node_modules'
]
This is the walk-up in action: Node starts in the requiring file’s own folder and adds a node_modules candidate at every directory level up to the filesystem root. A package matching at any of these levels is used immediately — the search does not continue past the first hit.
Example 3: require() caching and singleton state
Save counter.js:
let count = 0;
function increment() {
count += 1;
return count;
}
module.exports = { increment };
And main-counter.js:
const counterA = require("./counter");
const counterB = require("./counter");
console.log(counterA.increment());
console.log(counterB.increment());
console.log(counterA === counterB);
Output:
1
2
true
Node caches modules by their fully resolved absolute path, not by the specifier string you typed. Both require calls resolve to the same file, so the second call returns the exact same module.exports object from cache instead of re-running counter.js. That’s why the counter keeps incrementing across both variables, and why counterA === counterB is true. This is also how singletons (a shared database connection, a config object) are conventionally implemented in Node — and why two different resolved copies of the same package (from a nested node_modules) are NOT the same object, which occasionally causes confusing instanceof failures.
How Resolution Works Step by Step
For a call like require("./utils") made from /app/server.js, Node performs roughly this sequence synchronously, before your code continues:
- 1. Is
./utilsa core module name? No (it starts with./), so skip straight to file resolution. - 2. Build the absolute candidate path:
/app/utils. - 3. Does
/app/utilsexist as an exact file? Usually not, since there’s no extension. - 4. Try
/app/utils.js, then/app/utils.json, then/app/utils.node, in that order. First match wins. - 5. If none of those exist, treat
/app/utilsas a directory: look for/app/utils/package.jsonand read its"main"field, or fall back to/app/utils/index.js. - 6. If nothing matches at all, throw
MODULE_NOT_FOUND. - 7. Once a file is found, check
require.cachefor that exact resolved path. If present, return the cachedmodule.exportsimmediately without re-executing anything. - 8. Otherwise, read the file, wrap it in Node’s module function wrapper, execute it synchronously top to bottom, cache the resulting
module.exportsby resolved path, and return it.
For a bare specifier like require("express"), step 2 is replaced by the node_modules walk-up described earlier, but everything from step 5 onward (file vs. directory resolution, package.json "main"/"exports", caching) is identical.
Common Mistakes
1. Forgetting the ./ prefix for local files
// utils.js sits right next to app.js
const utils = require("utils"); // missing ./
console.log(utils.double(4));
This throws Error: Cannot find module 'utils'. Without a leading ./, ../, or /, Node never even looks in the current directory — it assumes you meant an installed package and only searches node_modules. The fix is to be explicit:
const utils = require("./utils");
console.log(utils.double(4));
Output:
8
2. Relying on case-insensitive filesystems
// actual file on disk is utils.js (lowercase)
const utils = require("./Utils");
On Windows and default macOS filesystems this silently works, because their filesystems are case-insensitive. Deploy the same code to a Linux server or a Docker container (both case-sensitive), and it throws Cannot find module. Always match the exact case of the file on disk — don’t rely on your development machine’s filesystem being forgiving.
3. Assuming circular requires give you a complete module
When module A requires module B, and B requires A back before A has finished running, B receives only the partial exports A had defined up to that point — not the final version. Save these three files and run node main.js:
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
Output:
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
Notice b.js sees a.done as false, because it requires a.js while a.js is still mid-execution and hasn’t reached its final exports.done = true line yet. Circular dependencies aren’t errors in Node, but they hand out incomplete snapshots — restructure the code so shared state lives in a third module both sides require, instead of requiring each other directly.
Best Practices
- Use the
node:prefix for core modules (require("node:fs")) so it’s unambiguous at a glance that a module is built-in, not installed. - Always prefix local files with
./or../; never rely on Node accidentally finding a same-named package innode_modules. - Define an
"exports"field in your own package’spackage.jsonto explicitly control what’s public, instead of letting consumers deep-import arbitrary internal files. - Avoid deep-importing into third-party packages’ internals (e.g.
some-lib/lib/helpers/x.js) unless that path is documented and exposed via"exports"— it can break silently on any upgrade. - Commit
package-lock.jsonso everyone (and CI) resolves the exact same dependency tree; never commitnode_modulesitself. - Run
npm ls <package>when you suspect duplicate installs (e.g. multiple React or lodash copies) — it shows exactly where nested copies live in the tree. - Treat filesystem case sensitivity as the default assumption, even if your laptop hides the problem.
Practice Exercises
- Create a small project with
app.jsat the root and alib/math.jsfile exporting anaddfunction. Require it fromapp.jsusing a relative path, then movemath.jsone directory deeper and update the path so it still resolves. - In any Node project with dependencies installed, run
node -e "console.log(module.paths)"from two files at different folder depths and compare the arrays — confirm each one walks up to the same project root. - Install any small package (for example
npm install kleur), then runnpm ls kleur. If your project has more than one version installed anywhere in the tree, note which parent package pulled in the duplicate and why npm couldn’t flatten it.
Summary
- Node classifies every
require()specifier as a core module, a file path (./,../, or/), or a bare package name, and resolves each differently. - File resolution tries the exact path, then
.js/.json/.nodeextensions, then falls back to treating the path as a directory with apackage.json"main"or anindex.js. - Bare package specifiers trigger a
node_moduleswalk-up from the requiring file’s directory to the filesystem root; the first match wins, which is also why nestednode_modulesfolders exist for conflicting dependency versions. - A
package.json"exports"field overrides"main"and encapsulates a package, blocking deep imports into paths it doesn’t explicitly list. require()caches modules by their resolved absolute path, so repeated requires of the same file return the identical object — this is what makes module-level singletons work, and what makes circular requires return partial exports.
