The module resolution algorithm: how Node finds your code
CJS require() runs a synchronous algorithm — resolve the specifier kind, probe file then directory then index, walk node_modules up the tree — while ESM resolves URLs with mandatory extensions. Knowing the exact steps turns MODULE_NOT_FOUND from a mystery into a checklist.
Error: Cannot find module './utils' — but the file is right there, you can see utils.ts in the editor. Or the reverse: a deploy works on your laptop and throws ERR_MODULE_NOT_FOUND in the container, on a specifier that never changed. Both come from the same place: a precise, multi-step resolution algorithm that runs every time you require() or import, and it does not do what you assume. It probes extensions in a fixed order, treats a directory differently from a file, and — under ESM — refuses to guess an extension at all. Once you can run the algorithm in your head, these errors stop being luck.
Three kinds of specifier, and the fork they trigger
Before Node looks at a single byte on disk, it classifies the specifier — the string you passed to require() or import — into one of three kinds, because each takes a different path through the algorithm:
- Relative — starts with
./,../, or/.require("./utils"). Resolved against the directory of the importing file. - Bare (a.k.a. “package”) — a name with no leading dot or slash.
require("express"),import "lodash/fp". Triggers thenode_moduleslookup walk. - Absolute / URL — a full filesystem path, or under ESM a
file:/node:/data:URL.import "node:fs".
require("./parser"); // relative → resolve next to the current file
require("parser"); // bare → search node_modules up the tree
require("/opt/app/p.js"); // absolute → use the path directlyThis first decision is the one people skip. "./utils" and "utils" look almost identical but route through completely different machinery: the first never touches node_modules, the second never looks next to your file. A leading ./ you forgot — or one you added by accident — sends resolution down the wrong branch entirely.
CJS: probe file, then directory, then index
For a relative specifier in CommonJS, Node takes the path and tries, in strict order, until one hits:
- As an exact file.
LOAD_AS_FILE(X): tryXliterally, thenX.js, thenX.json, thenX.node(a compiled addon). This is extension probing —require("./utils")quietly becomes./utils.js. The order is fixed: a siblingutils.jsalways wins overutils.json. - As a directory. If
Xis a directory,LOAD_AS_DIRECTORY(X): readX/package.jsonand follow itsmainfield (resolved again as a file); if there is nopackage.jsonor nomain, fall back toX/index.js, thenX/index.json, thenX/index.node. - Fail. If neither works, throw
MODULE_NOT_FOUND.
// require("./shapes") tries, in order:
// ./shapes (exact file — usually absent)
// ./shapes.js
// ./shapes.json
// ./shapes.node
// ./shapes/ → its package.json "main", else ./shapes/index.js …Two takeaways for the Cannot find module './utils' mystery. First, CJS does not resolve .ts — require only knows .js/.json/.node, so a bare ./utils pointing at utils.ts fails unless a loader (ts-node, tsx, a bundler) taught Node that extension. Second, a folder “just works” only because of the index.js fallback; delete the index and the same require("./shapes") breaks even though the directory still exists.
The node_modules walk: bare specifiers climb the tree
A bare specifier triggers the most distinctive part of the algorithm: NODE_MODULES_PATHS. Node does not have one global library directory. Instead, starting from the importing file’s directory, it looks for ./node_modules/<pkg>, and if that is absent it goes up one directory and tries again, repeating until it reaches the filesystem root.
import in /app/src/api/handler.js → require("lodash")
search /app/src/api/node_modules/lodash
search /app/src/node_modules/lodash
search /app/node_modules/lodash ← found here
search /node_modules/lodash (only if still missing)Once it finds the lodash directory, it resolves inside it with the same file/directory/index logic (honouring that package’s package.json main or exports). This upward walk is exactly why a dependency installed at the project root is visible to deeply nested files, and why two copies of a library at different depths can coexist — each importer resolves to the nearest one. It is also why MODULE_NOT_FOUND on a bare name almost always means “not installed at or above this path,” not “typo in the filename.”
▸Why this works
This upward walk is the mechanism behind npm’s dependency deduplication and the dreaded “two Reacts” bug. A package hoisted to /app/node_modules is shared by every importer below it. But if a transitive dependency pins a different version, npm nests a second copy in its node_modules, and an importer there resolves to the nested copy. Now two module instances of the “same” library exist, each with its own state — which breaks anything relying on a singleton (React hooks, instanceof checks). The fix is to dedupe or hoist to a single version, and understanding the walk is what tells you where each importer is actually resolving.
ESM resolution: URLs, mandatory extensions, no index
ESM runs a different algorithm, and the differences are exactly where the cross-environment failures live. ESM resolves everything to a URL (usually file:), and that reframing changes the rules:
- Extensions are mandatory.
import "./utils.js"works;import "./utils"throwsERR_MODULE_NOT_FOUND. There is no extension probing and no directoryindex.jsfallback for relative specifiers — you write the full path or it fails. (This is the single most common surprise migrating CJS code to ESM.) - Bare specifiers still use the
node_moduleswalk, but the package’s entry point is chosen by itsexports/mainfield, and subpaths must be explicitly exported (the next lesson). node:is explicit. Built-ins resolve via thenode:scheme (import "node:fs"); the bare formimport "fs"still works for built-ins butnode:is unambiguous and the modern default.
import { readFile } from "node:fs/promises"; // built-in, explicit scheme
import { parse } from "./parser.js"; // extension REQUIRED
import express from "express"; // bare → node_modules walk + exports
// import { parse } from "./parser"; // ✗ ERR_MODULE_NOT_FOUND under ESM| Behaviour | CommonJS require() | ESM import |
|---|---|---|
| Extension on relative path | Optional — probed (.js, .json, .node) | Mandatory — written in full |
Directory index fallback | Yes (index.js …) | No |
| What it resolves to | A filesystem path | A URL (file:, node:, data:) |
| Bare specifier | node_modules walk + main | node_modules walk + exports |
| Built-in module | require(“fs”) | import “node:fs” (explicit scheme) |
The package.json fields that steer resolution
Three package.json fields decide how a package (a bare import) resolves, and they form a precedence chain:
"type"—"module"makes.jsfiles in that package ESM;"commonjs"or absent makes them CJS. This governs how the resolved file is interpreted, not where it is found."main"— the legacy entry point: the file a barerequire("pkg")/import "pkg"resolves to whenexportsis absent."main": "./lib/index.js"."exports"— the modern entry map. When present, it overridesmainand becomes authoritative: it can routeimportandrequireto different files and, crucially, it blocks any path not listed (covered fully next lesson)."module"is a non-standard bundler-only field — Node ignores it.
The precedence is the gotcha: if a package ships an exports field, main is ignored by Node, and a deep import like require("pkg/lib/internal") that worked for years can suddenly throw ERR_PACKAGE_PATH_NOT_EXPORTED after the package adds exports. The file still exists; the resolver now refuses to look at it.
The same import('./parser') succeeds in a CommonJS file but throws ERR_MODULE_NOT_FOUND once the file is ESM. Why?
require('lodash') in /app/src/api/handler.js fails with MODULE_NOT_FOUND, but lodash is installed in /app/node_modules. What is the algorithm doing?
A TypeScript project compiles to ESM. Relative imports keep throwing ERR_MODULE_NOT_FOUND at runtime because the emitted .js has no extensions. How do you fix it durably?
- 01Walk through what require('./shapes') does step by step, and where each step can fail.
- 02Why does the same './parser' specifier work under CommonJS but throw ERR_MODULE_NOT_FOUND under ESM, and what are the two systems doing differently?
Module resolution is a deterministic algorithm that runs on every require() and import, and it begins by classifying the specifier into relative (./, ../, /), bare (a package name), or absolute/URL — each routing through different machinery. For a relative specifier, CommonJS runs LOAD_AS_FILE (try the exact path, then probe .js, .json, .node) and then LOAD_AS_DIRECTORY (follow package.json main, else fall back to index.js/json/node), throwing MODULE_NOT_FOUND if both miss; this is why extensions are optional and folders “just work”. For a bare specifier, CJS runs the node_modules walk — starting at the importing file’s directory and climbing up the tree, checking node_modules at each level — which is the mechanism behind dependency hoisting, deduplication, and the “two copies of one library” bug. ESM runs a different, URL-based algorithm where extensions are mandatory and there is no index fallback, so ’./parser’ must be written ’./parser.js’; bare specifiers still walk node_modules but pick their entry through the package’s exports field, and built-ins use the explicit node: scheme. Three package.json fields steer package resolution: “type” decides whether resolved .js is CJS or ESM, “main” is the legacy entry point, and “exports” — when present — overrides main and becomes authoritative, which is why adding it can suddenly break a deep import that worked for years. Run the algorithm in your head and MODULE_NOT_FOUND turns from a mystery into a short checklist.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.