Node.js — MODULE_NOT_FOUND in Production from a Phantom Dependency
Track down Cannot find module errors that only appear in production, identify packages that worked via hoisting, and pin every real import as a declared dependency.
Symptoms
- Cannot find module 'lodash' even though the code has worked locally for months
- The error appears after a fresh npm ci --omit=dev on the build server, not on developer laptops
- npm ls lodash reports UNMET DEPENDENCY or shows it only nested under another package
Possible causes
- The package was imported without being declared in package.json and resolved only through hoisting from a transitive dependency
- A lockfile regeneration changed the tree layout, so the transitive package no longer shares the phantom at the top level
- Development worked because devDependencies of a sibling package hoisted the module to the root node_modules
- pnpm's strict node_modules layout exposed the undeclared import that npm had silently tolerated
Diagnosis steps
Compare the failing resolution path
node -e "console.log(require.resolve('lodash'))" # on server vs laptopInterpretation
A path that dives into another package's node_modules on the laptop and throws on the server confirms reliance on hoisting.
Audit undeclared imports mechanically
npx depcheck --ignores=eslintInterpretation
depcheck lists modules imported in code but missing from package.json dependencies. Each one is a production outage waiting for a lockfile change.
Inspect the actual tree shape
npm ls lodash 2>&1 | head -20Interpretation
UNMET or nested-only output means your package never owned that dependency; it was borrowed.
Root cause
The code imported a package that was never declared as a direct dependency. Resolution succeeded only because npm hoisted a transitive copy to the root of node_modules, and that layout changed after a clean install or a switch to a stricter package manager.
Fix
Declare every imported package in dependencies with an explicit version range, run a clean install in CI with the same package manager and lockfile as production, and adopt pnpm or npm ls audits in CI so undeclared imports fail the build instead of the deployment.
Risk note
Fixing only the one failing module leaves the rest of the phantom imports armed. Run the full audit and declare them all in the same change.
Verify the fix
Delete node_modules and the lockfile cache, run npm ci --omit=dev from scratch, and boot the app; then re-run depcheck and confirm it reports zero missing dependencies.
Symptom walkthrough
The failure only visits environments that install from a clean tree:
node:internal/modules/cjs/loader:1080
throw err;
^
Error: Cannot find module 'lodash'
Developer laptops keep working because their node_modules grew incrementally and still contains the hoisted copy. Compare resolutions to make the difference concrete:
# laptop: node_modules/lodash (hoisted, borrowed)
# server: throws MODULE_NOT_FOUND
npm ls lodash on the laptop shows it nested under another package, which is the receipt proving your app never owned it.
Fix in practice
Enumerate the debt first, then declare it all at once:
npx depcheck
npm install lodash@^4.17.21
Add a CI step that installs exactly like production and starts the app, because that is the only test that reproduces hoisting luck:
- run: npm ci --omit=dev
- run: node server.js & sleep 5 && curl -f localhost:3000/health
For teams that want the guarantee structural, pnpm rejects undeclared imports by default, turning the outage class into a build error.
Verification
Simulate the worst case deliberately: remove node_modules, install with the production flag from a clean cache, and boot. The app must start with zero MODULE_NOT_FOUND. Re-run depcheck and require an empty missing list. Finally, regenerate the lockfile once and repeat the clean boot to prove the tree layout no longer decides whether your imports resolve.