require Resolution Algorithm

intermediate modules require resolution node_modules

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

  1. Is X a core module (fs, http, path, …)? Use that.
  2. Does X start with ./, /, or ../? Treat it as a file or directory path.
  3. Otherwise, walk node_modules up the directory tree until found.

If none of these work, we get the famous Error: Cannot find module 'X'.

require("X") flowchart
Step 1: Is X a core module like "fs" or "node:http"? → return it.
↓ no
Step 2: Does X start with "./", "/", "../"? → resolve as file/dir path.
↓ no
Step 3: Walk up node_modules folders from current dir to root.
↓ found?
Load it. Otherwise throw MODULE_NOT_FOUND.

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:

  1. If exports field exists → use that (conditional exports, see below).
  2. Else if main field exists → use that file.
  3. 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_modules shadows a hoisted version. Run require.resolve to 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-symlinks for some monorepo setups.