open atlas
↑ Back to track
Node.js, zero to senior NODE · 13 · 01

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.

NODE Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

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 the node_modules lookup 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 directly

This 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:

  1. As an exact file. LOAD_AS_FILE(X): try X literally, then X.js, then X.json, then X.node (a compiled addon). This is extension probingrequire("./utils") quietly becomes ./utils.js. The order is fixed: a sibling utils.js always wins over utils.json.
  2. As a directory. If X is a directory, LOAD_AS_DIRECTORY(X): read X/package.json and follow its main field (resolved again as a file); if there is no package.json or no main, fall back to X/index.js, then X/index.json, then X/index.node.
  3. 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 .tsrequire 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" throws ERR_MODULE_NOT_FOUND. There is no extension probing and no directory index.js fallback 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_modules walk, but the package’s entry point is chosen by its exports / main field, and subpaths must be explicitly exported (the next lesson).
  • node: is explicit. Built-ins resolve via the node: scheme (import "node:fs"); the bare form import "fs" still works for built-ins but node: 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
BehaviourCommonJS require()ESM import
Extension on relative pathOptional — probed (.js, .json, .node)Mandatory — written in full
Directory index fallbackYes (index.js …)No
What it resolves toA filesystem pathA URL (file:, node:, data:)
Bare specifiernode_modules walk + mainnode_modules walk + exports
Built-in modulerequire(“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 .js files 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 bare require("pkg")/import "pkg" resolves to when exports is absent. "main": "./lib/index.js".
  • "exports" — the modern entry map. When present, it overrides main and becomes authoritative: it can route import and require to 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.

Quiz

The same import('./parser') succeeds in a CommonJS file but throws ERR_MODULE_NOT_FOUND once the file is ESM. Why?

Quiz

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?

Pick the best fit

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?

Recall before you leave
  1. 01
    Walk through what require('./shapes') does step by step, and where each step can fail.
  2. 02
    Why does the same './parser' specifier work under CommonJS but throw ERR_MODULE_NOT_FOUND under ESM, and what are the two systems doing differently?
Recap

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.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.