What this usually means
Node.js follows a strict resolution algorithm: it looks for core modules, then checks each parent directory's node_modules, then checks global paths. When it can't find a module, the cause is almost never a missing npm install (you'd see a different error). Instead, it's usually a symlink that didn't get properly created (common in workspaces), a missing entry in package.json exports or main, a case mismatch in the filename on case-sensitive file systems, or a dependency hoisting issue in a monorepo where a nested package relies on a peer dependency that got hoisted to a different level. Another common cause is a postinstall script that failed silently, leaving an incomplete node_modules tree.
The first ten minutes — establish facts before touching code.
- 1Run `node -e "console.log(require.resolve('the-module'))"` to see exactly where Node is looking
- 2Check if the module actually exists: `ls -la node_modules/the-module` — look for broken symlinks (flashing red) or missing package.json
- 3Verify the file path case: `find . -iname 'thefile.js'` on Linux/macOS — if it matches but your import uses different case, Node will fail
- 4Run `npm ls the-module` to see if the module is installed and at what version — look for MISSING or UNMET DEPENDENCY flags
- 5For monorepos, check the hoisting level: `npm ls --depth=0` — if the module is in a nested node_modules but your code expects it at root, you have a hoisting problem
The specific files, logs, configs, and dashboards that usually own this bug.
- searchnode_modules/the-module/package.json — specifically the main, exports, and bin fields
- search.npmrc — for registry misconfiguration or legacy-peer-deps flag
- searchpackage.json of the consuming module — check if the dependency is listed with a valid version range
- searchpackage-lock.json or yarn.lock — look for duplicate entries or resolved URLs that point to a private registry that's unreachable
- searchDockerfile — RUN npm ci vs npm install, and whether node_modules is copied or rebuilt
- searchCI logs — especially the npm install step for warnings about optional dependencies, deprecated packages, or audit errors that might indicate corruption
- searchSymlink targets: `readlink -f node_modules/the-module` to see where the symlink actually points
Practical causes, not theory. These are the things you will actually find.
- warningCase mismatch: Windows is case-insensitive, Linux/macOS is not. 'MyModule.js' vs 'mymodule.js'
- warningBroken symlink in monorepos: workspace dependencies get symlinked, but the target doesn't exist if the workspace package wasn't built
- warningMissing native addon: a dependency requires a compiled .node or .gyp file, and postinstall script failed (often due to missing build tools like python or g++). The module exists but can't be loaded
- warningPackage.json exports field: if the module uses the exports map, importing a subpath that isn't listed will fail silently
- warningHoisting conflict: two versions of the same package exist in different node_modules, and the resolver picks the wrong one (or none) due to semver mismatches
- warningGlobal install shadowing: a globally installed module with a different version is picked up locally, but its dependencies are missing
Concrete fix directions. Pick the one that matches your root cause.
- buildExplicitly install the missing peer dependency: `npm install the-module@version` or add it to your package.json
- buildUse `npm dedupe` to flatten duplicate packages and resolve hoisting issues
- buildFor monorepos, set `nohoist: ["**/some-package"]` in the root package.json to prevent hoisting of specific packages
- buildIf it's a native addon, ensure build tools are installed: `apt install build-essential python3` on Ubuntu or `xcode-select --install` on macOS, then `npm rebuild`
- buildFor case issues, rename the file to match the import: `mv MyFile.js myfile.js` on a case-sensitive filesystem
- buildDelete node_modules and package-lock.json, then reinstall: `rm -rf node_modules package-lock.json && npm install` — this forces a fresh resolution tree
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter the fix, run `node -e "require('the-module')"` — no error means resolution works
- verifiedRun your test suite: `npm test` — all tests that import the module should pass
- verifiedRun `npm ls the-module` again — it should show the correct version and no unmet dependencies
- verifiedFor CI/Docker, rebuild the image or pipeline from scratch: `docker build --no-cache .` or clean CI run
- verifiedCheck that the module's entry file actually exports what you expect: `node -e "const m = require('the-module'); console.log(Object.keys(m))"`
Things that make this bug worse or harder to find.
- warningNever blindly delete node_modules and reinstall without first checking the lockfile — you might lose a legitimate conflict resolution
- warningDon't use `npm install --force` unless you absolutely must — it bypasses the lockfile and can introduce version mismatches
- warningAvoid fixing symlink issues by copying files manually — it breaks the monorepo workspace contract and leads to stale code
- warningDon't ignore the error stack trace — it tells you exactly which module failed to resolve and from which file. The answer is in the trace, not in the error message alone
- warningNever commit node_modules or symlinks — they are not portable across systems
- warningDon't assume the module name in the error is the real problem — it may be a dependency of a dependency, so check the full dependency tree
The Case of the Missing Symlink: A Monorepo CI Failure
Timeline
- 09:15CI build fails on PR #234 with 'Cannot find module @myorg/data-access' — the module exists in node_modules/@myorg/data-access but is empty
- 09:20Check package.json: @myorg/data-access is a workspace dependency listed in root package.json workspaces
- 09:25Run `ls -la node_modules/@myorg/data-access` — shows a broken symlink (red, flashing). The symlink points to ../../packages/data-access but that directory doesn't exist (typo: 'data-acces' vs 'data-access')
- 09:30Check the actual workspace directory: `ls packages/` — it's 'data-access' (correct spelling). The symlink in node_modules has a typo: 'data-acces' (missing 's')
- 09:35Look at the root package.json workspaces field: it says 'packages/*' — that's fine. The symlink creation is handled by npm/yarn, not manually. So why the typo?
- 09:40Check git history: a previous developer renamed 'packages/data-access' to 'packages/data-access' (same name) in a commit that also changed the package's name in its package.json to '@myorg/data-acces' (typo). The workspace symlink was created based on the package name, not the folder name
- 09:45Fix: rename package name in packages/data-access/package.json from '@myorg/data-acces' back to '@myorg/data-access'. Then run `yarn install --force` to recreate symlinks
- 09:50CI passes. Root cause: a package.json name field mismatch between the workspace directory and the package name caused a broken symlink
The CI build had been failing for three hours. Every push to PR #234 produced the same error: 'Cannot find module @myorg/data-access'. The module was listed in the root package.json as a workspace dependency. I checked node_modules/@myorg/data-access — the directory existed but was empty. A symlink that went nowhere. The actual package source lived in packages/data-access, but the symlink pointed to a non-existent path: packages/data-acces. Someone had introduced a typo in the package name field of the workspace's package.json.
The previous developer had renamed the package's name from '@myorg/data-access' to '@myorg/data-acces' in a commit that was supposed to fix a different issue. They had also changed the folder name (or not — the folder was still 'data-access'). But npm/yarn uses the package name (from package.json) to create the symlink in node_modules, not the folder name. So the symlink became '@myorg/data-acces' -> '../../packages/data-acces'. The folder didn't exist, so the symlink was broken. The error message said 'data-access' because that's what the importing code used, but the actual symlink was 'data-acces'.
The fix was simple: correct the package name back to '@myorg/data-access' in packages/data-access/package.json, then force reinstall to recreate the symlinks. I also added a CI check that validates all workspace symlinks point to existing directories. The lesson: always verify that the package name in package.json matches the directory name in a monorepo, and never assume the error message tells the whole story. The module resolution algorithm is strict — a single character typo can break the entire dependency chain.
Root cause
Typo in the package name field of a workspace package's package.json caused npm/yarn to create a symlink with a misspelled target path, resulting in a broken symlink and 'cannot find module' error.
The fix
Corrected the package name in packages/data-access/package.json from '@myorg/data-acces' to '@myorg/data-access' and ran `npm install` to recreate symlinks.
The lesson
In monorepos, the package name in package.json determines the symlink name in node_modules, not the folder name. Always double-check that they match. Also, CI should validate that all workspace symlinks point to existing directories.
When Node.js encounters a `require('some-module')`, it follows a deterministic algorithm: first, it checks if 'some-module' is a core module (like 'fs' or 'path'). If not, it looks for a file or directory named 'some-module' in the current directory's `node_modules`. If not found, it moves to the parent directory's `node_modules`, and so on, all the way up to the root. If still not found, it checks global install paths (like `/usr/local/lib/node_modules`). Finally, it throws the 'Cannot find module' error.
The key detail often missed: Node.js will resolve a package.jsons `main` field, but also the `exports` field if present (since Node 12). If `exports` is defined, it restricts what can be imported from the package. For example, a package with `"exports": { "./utils": "./dist/utils.js" }` will allow `require('pkg/utils')` but deny `require('pkg/subdir/secret.js')`. Many developers hit this when they try to import a file that isn't listed in `exports`. The fix is either to add the subpath to `exports` or import the default entry point.
Another nuance: Node.js caches resolved modules in `require.cache`. If you have a broken symlink that was resolved once (maybe to a different path), subsequent requires will use the cached result. Deleting the cache or restarting the process is necessary. Also, `require.resolve()` does not load the module — it only resolves the path, which is useful for debugging without side effects.
In monorepo setups (npm workspaces, Yarn workspaces, pnpm), dependencies are installed in a root `node_modules` and symlinked from workspace packages. A common failure is when a workspace package's `node_modules/.bin` contains a symlink to a binary that doesn't exist, or when a dependency's symlink points to a different version than expected. This often manifests as 'Cannot find module' for a transitive dependency that exists somewhere in the tree but not in the exact location Node is searching.
To diagnose symlink issues, use `ls -la node_modules/@scope/pkg` to see the symlink target. Then `readlink -f` to get the absolute path. Verify that path exists. For hoisting issues, check if the module is installed at multiple levels: `npm ls --all | grep module-name`. If you see two different versions, Node might pick the wrong one depending on the require path. The fix is often to align versions across workspaces using a `resolutions` field in package.json or `npm dedupe`.
pnpm users have a different beast: pnpm uses hard links and a content-addressable store. 'Cannot find module' errors there often stem from a missing `--shamefully-hoist` flag or from a package that expects to find its dependencies in the root `node_modules` but pnpm hasn't hoisted them. Check if the module is in `.pnpm` store and if the symlink to the store is correct.
The classic 'works on my machine' bug. Windows filesystems (NTFS) are case-insensitive by default, so `require('./MyModule')` and `require('./mymodule')` both find the same file. Linux (ext4, XFS) and macOS (APFS, case-sensitive by default since macOS 10.13) treat them as different files. If you develop on Windows and deploy to Linux, an accidental case mismatch will trigger 'Cannot find module'.
To catch this early, configure ESLint with `import/no-unresolved` rule and set it to case-sensitive. Also, run `find . -name '*.js' -exec sh -c 'echo {} | grep -E [A-Z]' \;` to list all files with uppercase characters — they might be the culprit. The fix is to rename the file to match the import case. A good practice is to enforce lowercase file names in your project (except for React components, which conventionally use PascalCase).
Note that npm itself on Linux will fail to install a package if the tarball contains files with the same name but different case. This can lead to incomplete installations and 'Cannot find module' errors for specific files.
Modules that contain native code (like `node-canvas`, `sharp`, `sqlite3`) rely on `node-gyp` to compile during installation. If the build environment lacks necessary tools (Python, C++ compiler, make), the postinstall script fails silently (or with a warning), leaving behind an incomplete module that has a package.json but no compiled `.node` file. When you require the module, Node.js tries to load the native addon and fails with 'Cannot find module' or 'Module did not self-register'.
Check if a native module is the culprit by looking at the error stack trace: if it mentions `.node` or `Module._load`, it's likely a native addon. Run `npm rebuild` to recompile all native modules. If that fails, install build tools: on Ubuntu, `sudo apt install build-essential python3`; on macOS, `xcode-select --install`. For Docker, ensure the base image includes these tools. Also, consider using `prebuild-install` or prebuilt binaries to avoid compilation entirely.
Another scenario: a native module requires a specific platform (e.g., `darwin` vs `linux`). If you npm install on macOS and then copy node_modules to a Linux container, the .node files won't match the architecture. Always run `npm ci` or `npm install` in the target environment to ensure native addons are compiled for the correct platform.
Since Node.js 12.7.0, packages can define an `exports` field to control which subpaths are publicly accessible. This is a double-edged sword: it improves encapsulation but can also cause 'Cannot find module' when you try to import a subpath that the package author didn't expose. For example, importing from `lodash/array` works because lodash's package.json has `"exports": { "./array": "./array.js" }`. But if you try `lodash/internal/private.js`, you'll get an error even though the file exists on disk.
To debug, first check the module's package.json: look for `exports`. If it exists, only the listed subpaths are importable. If the subpath you need is missing, either import the default entry or ask the package maintainer to add the subpath. For your own packages, be deliberate about what you expose. A common mistake is to have `exports` defined but not list all the files you intend to be public, causing internal imports to fail in consuming projects.
Also note that `exports` can be conditional, e.g., `{ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }`. If you use CommonJS and the package doesn't have a `require` condition, Node.js will fail to resolve it. Always test both ESM and CommonJS entry points if your package supports both.
Frequently asked questions
Why does 'Cannot find module' happen after a fresh install when the file clearly exists in node_modules?
Most likely a broken symlink in a monorepo or a case mismatch. Run `readlink -f node_modules/the-module` to see if the symlink points to a real directory. Also check that the file's case matches the import statement exactly. Another possibility: the module's package.json `main` field points to a file that doesn't exist.
The error says 'Cannot find module' but the module is listed in package.json. What gives?
The module might not be installed due to a failed postinstall script or a version conflict. Run `npm ls the-module` to see if it's actually installed. If it shows 'UNMET DEPENDENCY', then the installation failed. Check the npm install logs for warnings about optional dependencies or peer dependency conflicts.
How do I fix a 'Cannot find module' error for a local file that I'm sure exists?
First, verify the path is correct: use `require.resolve('./relative-path')` to see what Node resolves. If it's a symlink, ensure the target exists. If the path looks correct but still fails, check for case sensitivity. Also, if you're using TypeScript, make sure the file extension is correct (e.g., `.ts` vs `.js`). In some setups, you might need to compile TypeScript first.
Why does the error occur only in CI and not on my local machine?
Common reasons: CI uses a different operating system (case sensitivity), different Node version (affects exports field behavior), or a clean build without cached dependencies (so symlinks are fresh). Also, CI might have different environment variables or registry settings. Compare the npm install output between local and CI. Also check if CI uses a different package manager (e.g., yarn vs npm) that handles hoisting differently.
What's the difference between 'Cannot find module' and 'Module not found' errors in Node.js?
In the context of Node.js runtime (not bundlers like webpack), the error message is always 'Cannot find module'. 'Module not found' is typically from TypeScript compiler or bundlers. If you see 'Cannot find module' in Node.js, it means the module could not be resolved at runtime. If you see 'Module not found' in a build tool, it's a separate issue related to module resolution configuration (like tsconfig paths or webpack resolve aliases).