On This Page
1. The Error
You run a script and require() throws before your app does anything useful:
node:internal/modules/cjs/loader:1386
throw err;
^
Error: Cannot find module 'express'
Require stack:
- /home/claude/app.js
at Function._resolveFilename (node:internal/modules/cjs/loader:1383:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1025:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1030:22)
at Function._load (node:internal/modules/cjs/loader:1192:37)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
at Module.require (node:internal/modules/cjs/loader:1463:12)
at require (node:internal/modules/helpers:147:16)
at Object.<anonymous> (/home/claude/app.js:1:17)
at Module._compile (node:internal/modules/cjs/loader:1705:14) {
code: 'MODULE_NOT_FOUND',
requireStack: [ '/home/claude/app.js' ]
}
Node.js v22.22.2That's the real output of require('express') against Node 22.22.2 with no express in node_modules. Two details matter more than people think: the requireStack array (it tells you which file issued the failing require, useful once you have more than one), and the frame names, which are not stable across Node versions.
On Node 20.x and 21.x — the pre-refactor CJS loader — the same failure prints with different internal frame names:
node:internal/modules/cjs/loader:1210
throw err;
^
Error: Cannot find module 'express'
Require stack:
- /home/claude/app.js
at Module._resolveFilename (node:internal/modules/cjs/loader:1207:15)
at Module._load (node:internal/modules/cjs/loader:1038:27)
at Module.require (node:internal/modules/cjs/loader:1289:19)
at require (node:internal/modules/helpers:182:18)
at Object.<anonymous> (/home/claude/app.js:1:17)
at Module._compile (node:internal/modules/cjs/loader:1521:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1623:10)
at Module.load (node:internal/modules/cjs/loader:1266:32)
at Module._load (node:internal/modules/cjs/loader:1091:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:164:12) {
code: 'MODULE_NOT_FOUND',
requireStack: [ '/home/claude/app.js' ]
}
Node.js v20.20.2The message and err.code ('MODULE_NOT_FOUND') have been stable for years — that part is safe to grep for in logs across any Node version currently in support. The stack frames are not: Node 22 replaced the old Module._resolveFilename / Module._load chain with resolveForCJSWithHooks / defaultResolveImpl, part of unifying CJS and ESM resolution internals. Don't hardcode a line number from a stack trace into a test assertion or a log parser — it moves every patch release; only err.code and the message text are contractually stable.
This is not the ESM sibling of this error. If you're looking at Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/utils' imported from /app/index.js, that's a different code path with different rules (mandatory file extensions, no directory index fallback) — see the connection note at the end.
2. How to Reproduce It (step-by-step)
Minimal case, CommonJS, no "type" field needed:
// app.js
const express = require('express');
const app = express();
app.listen(3000);node app.jsIf express was never installed in this project (or node_modules doesn't exist at all), you get exactly the output in Section 1. Confirm the absence with npm itself before touching code:
$ npm ls express
repro2@1.0.0 /home/claude/node-articles/repro2
`-- (empty)(Exit code 1 — npm ls treats a missing declared dependency as a failure, which is exactly what you want in a CI health check.)
A second, less obvious way to hit this: the package is installed, but not where you think. require() walks up the directory tree from the file doing the requiring, not from process.cwd(). Prove it:
project/
├── node_modules/
│ └── tinypkg/
│ ├── index.js // module.exports = 'hello from tinypkg';
│ └── package.json // { "name": "tinypkg", "main": "index.js" }
└── src/
└── nested/
└── app.js // console.log(require('tinypkg'));$ cd /tmp && node /home/claude/node-articles/pathwalk/src/nested/app.js
hello from tinypkg
cwd was: /tmpIt resolves even though the current shell is in /tmp and /tmp has no node_modules anywhere near it. node -e "console.log(require('module')._nodeModulePaths(process.cwd()))" run from src/nested shows the actual search list:
[
'/home/claude/node-articles/pathwalk/src/nested/node_modules',
'/home/claude/node-articles/pathwalk/src/node_modules',
'/home/claude/node-articles/pathwalk/node_modules',
'/home/claude/node-articles/node_modules',
'/home/claude/node_modules',
'/home/node_modules',
'/node_modules'
]That's the file's own directory, then every ancestor's node_modules, all the way to /. This is why "it works when I run it from the project root but not from the scripts/ folder" is almost never a node_modules problem — the walk is location-of-the-file-based, not shell-based — and is usually a relative path problem (require('./config') resolved against the wrong directory) instead.
Flags worth having on for diagnosis:
NODE_DEBUG=module node app.jsMODULE 2932: Module._load REQUEST express parent: .
MODULE 2932: looking for "express" in ["/home/claude/node-articles/repro-module-not-found/node_modules","/home/claude/node-articles/node_modules","/home/claude/node_modules","/home/node_modules","/node_modules", ...]That line is the entire algorithm laid bare — every directory Node actually checked, in order, before giving up.
3. Version Behaviour Matrix (Node.js 20 / 22 / 24 / 26)
| Version | Status (as of Sep 2026) | err.code / message | Internal frames |
|---|---|---|---|
| 20 | Security support ended 2026-04-30 (EOL) | MODULE_NOT_FOUND, same message | Old loader: Module._resolveFilename, Module._load, Module._extensions..js |
| 22 | Maintenance LTS (security support to 2027-04-30) | Same | Refactored loader: Function._resolveFilename, defaultResolveImpl, resolveForCJSWithHooks |
| 24 | Active LTS (security support to 2028-04-30) | Same | Same refactored loader as 22; verified against the lib/internal/modules/cjs/loader.js source for the v24.9.0 tag — same function names, nearby but not identical line numbers |
| 26 | Current (released 2026-05-05; enters LTS Oct 2026, security support to 2029-04-30) | Same | Same architecture; NODE_MODULE_VERSION bumped to 147 (irrelevant to this error, relevant if you also see native-addon ABI errors) |
This error is version-neutral in behavior — MODULE_NOT_FOUND for a missing CommonJS dependency has meant the same thing since Node 4. Nothing in 20 → 26 changes when it fires or what it means. What has moved is adjacent: require(esm) — synchronously require()-ing a real ES module — shipped experimentally in 20.19/22.12 and is unflagged and stable in current 22.x/24.x/26.x, which quietly eliminated most ERR_REQUIRE_ESM reports without touching MODULE_NOT_FOUND at all. One line to flag forward: Node 27 (April 2027) is the first release under the new one-major-per-year schedule, with an alpha channel (27.0.0-alpha.x) starting October 2026 — nothing publicly slated there changes CJS resolution, but it's the release to watch for module-system changes going forward since every line becomes LTS from here on.
4. Why It Happens — Surface Level
Almost always one of three things: the package genuinely isn't installed in this environment (fresh clone, npm install never ran, or it ran against the wrong lockfile); the package is installed but the require specifier or path is wrong (typo, wrong casing, wrong relative depth); or the package was installed for a different runtime than the one executing right now (host machine vs. Docker image, or a global install when the code expects a local one).
The Require stack array in the error is your fastest triage tool — it names the exact file that issued the require() call, which matters the moment you have more than one module in the picture and the failure isn't in the file you were just editing.
5. Why It Happens — Under the Hood
CommonJS require() resolution is a synchronous, four-step algorithm implemented in lib/internal/modules/cjs/loader.js: if the specifier is a core module (fs, node:fs), return it immediately; if it starts with ./, ../, or /, resolve it as a file path relative to the requiring module (trying the exact path, then path.js, path.json, path.node, then path/index.js); otherwise treat it as a package name and walk node_modules directories starting at the requiring file's own directory and going up to the filesystem root — this is the _nodeModulePaths() list you saw in Section 2. The walk stops and throws MODULE_NOT_FOUND only once every directory in that list has been checked and none contains a matching package.
Two mechanics explain the errors people actually paste into search engines. First, resolution is anchored to the file, not the process: module.paths is computed per-module from __dirname outward, which is why moving where you run node from (your shell's cwd) never changes what a bare require('lodash') finds, but moving the file between directories can. Second, every resolved module is cached in require.cache (technically Module._cache) keyed by absolute resolved path — a failed resolution is not cached, so a MODULE_NOT_FOUND inside a hot path (e.g., dynamic require(userInput)) re-walks the full directory chain on every call, which is a real, measurable cost in code that resolves module names at runtime.
Node 22 changed the shape of the stack trace without changing the outcome: CJS and ESM resolution were unified behind resolveForCJSWithHooks, which calls a defaultResolveImpl that still bottoms out in the same Module._resolveFilename logic and the same err.code = 'MODULE_NOT_FOUND' assignment — confirmed by diffing the actual throw err site across the 20.x, 22.x, and 24.x loader source. If you're parsing Node error stacks with a regex in a log pipeline, anchor on code: 'MODULE_NOT_FOUND' in the trailing property block, never on frame names or line numbers.
You can watch the exact algorithm run with NODE_DEBUG=module node app.js (Section 2) or interrogate it directly:
node -e "console.log(require('module')._nodeModulePaths(process.cwd()))"6. The Fix
Quick fix — the dependency truly isn't installed:
- $ node app.js
- Error: Cannot find module 'express'
+ $ npm install express
+ added 68 packages in 5s
+ $ node app.js
+ (starts cleanly)When it's a path/casing problem, not a missing package — fix the specifier, don't touch node_modules:
- const Utils = require('./Utils'); // fails on Linux CI, works on your Mac
+ const Utils = require('./utils'); // matches the actual filename on diskWhen it only fails in CI/Docker and works on your machine — the lockfile and the installed tree disagree. Use npm ci, not npm install, in any non-interactive environment; it fails fast on a stale lockfile instead of silently reconciling it:
# Dockerfile
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .npm ci deletes node_modules first and installs strictly from package-lock.json, so a lockfile that's out of sync with package.json fails the build immediately instead of shipping a container that's missing a dependency nobody noticed locally.
When the package is installed globally but required locally — this is a design smell, not a config fix: add it as a real dependency of the project (npm install <pkg>) rather than relying on a global install existing on every machine that runs this code.
7. Best Practices & The Better Design
Treat "does this repository run from a clean checkout?" as a CI gate, not an assumption. A pipeline stage that does git clone → npm ci → node app.js (or your test suite) on every PR catches this entire error class before it reaches a teammate or a production deploy — it's cheaper than any amount of individual debugging.
Prefer explicit, extension-inclusive relative imports for your own files (require('./utils.js') over require('./utils')) in codebases that get moved between case-sensitive Linux and case-insensitive macOS/Windows filesystems; it removes an entire category of "works on my machine" reports. And don't reach for NODE_PATH or global installs to paper over a missing local dependency — every package your code imports belongs in that project's own package.json, full stop; that's what makes npm ci a meaningful gate at all.
Rewritten the right way — dependency declared, lockfile committed, CI enforces it:
// package.json (CommonJS, no "type" field — defaults to CommonJS)
{
"name": "repro-module-not-found",
"version": "1.0.0",
"private": true,
"dependencies": {
"express": "^4.19.2"
},
"engines": {
"node": ">=20"
}
}// app.js — unchanged; the fix lives in the install step, not the code
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('ok'));
app.listen(3000);npm ci # fails loudly here if package.json and package-lock.json disagree
node app.js # only ever runs against a verified tree8. How to Prevent It Long-Term
Run npm ci (never npm install) in every CI job and every Dockerfile RUN step — it's the single highest-leverage change here, because it converts a silent runtime failure into a loud, immediate build failure. Add npm ls --all (or npm ls <pkg> for critical dependencies) as an explicit CI step; a missing declared dependency exits non-zero, which you can fail the build on before any test even runs. Commit package-lock.json and treat unreviewed lockfile diffs in PRs as a signal to look closer, not noise to ignore. If your team hits the "works locally, breaks in CI" variant of this repeatedly, add n/no-missing-import and n/no-extraneous-require from eslint-plugin-n to your ESLint config — they statically catch a require() of a package that isn't in package.json before it ever reaches a runtime.
For the path/casing variant specifically, run at least one CI job on a case-sensitive filesystem (any standard Linux runner already is) even if most of your engineers develop on macOS — that alone surfaces require('./Utils')-vs-utils.js mismatches that a Mac-only team will never see locally. Finally, log err.code and err.requireStack — not just err.message — whenever you catch a module-loading failure at a boundary (a plugin loader, a dynamic require()); the code is what's stable across Node versions, and the stack is what tells you which file to open first.
9. Key Takeaways / Learnings
Error: Cannot find modulewithcode: 'MODULE_NOT_FOUND'is CommonJS's "I walked everynode_modulesfrom your file up to/and found nothing" error — the message and code are stable across Node versions; the internal stack frame names are not (they changed in the Node 22 loader refactor).- Resolution for bare specifiers is anchored to the requiring file's directory, not
process.cwd()— moving your terminal doesn't matter, moving the file does. npm ls <pkg>andNODE_DEBUG=moduleturn a guess into a fact in seconds — use them before editing any code.- Use
npm ci, notnpm install, in CI and Docker builds; it's the fix that prevents the "works on my machine" version of this error entirely. - This is a different error from ESM's
ERR_MODULE_NOT_FOUND(mandatory extensions, no index fallback) — don't apply this article's fixes to that one.
Related, not yet covered in this series: ERR_MODULE_NOT_FOUND (the ESM sibling — mandatory file extensions, no directory-index fallback); ERR_PACKAGE_PATH_NOT_EXPORTED (a package is installed but its exports map blocks the subpath you're importing); case-sensitivity failures between macOS/Windows dev machines and Linux CI runners; npm ERR! code ERESOLVE (a different failure mode entirely — the tree resolves to a conflict instead of nothing).
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.