Why Node says ERR_MODULE_NOT_FOUND when the file exists

Node's ERR_MODULE_NOT_FOUND keeps firing when the file is right there. Every real cause, from missing extensions to broken package exports, plus the fix for each one.

nodejs esm modules typescript module-resolution debugging JavaScript
Bharath G
Reading Progress

On This Page

node:internal/modules/esm/resolve:275
    throw new ERR_MODULE_NOT_FOUND(
          ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/src/math' imported from /app/src/index.js
Did you mean to import "./math.js"?
    at finalizeResolution (node:internal/modules/esm/resolve:275:11)
    at moduleResolve (node:internal/modules/esm/resolve:861:10)
    at defaultResolve (node:internal/modules/esm/resolve:985:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:731:20)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:708:38)
    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:310:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///app/src/math'
}

Node.js v22.22.2

If that's your terminal right now, and you've already stared at the file, confirmed it exists, and confirmed the path is spelled correctly, skip to "the fix." If you want to know why Node is lying to you about a file it can clearly see, keep reading, because there are five different ways to land on this exact message and they don't all get fixed the same way.

ERR_MODULE_NOT_FOUND, word for word

That block above is the real thing, copied from a Node 22.22.2 run, not reconstructed from memory. Three parts matter.

The Error [ERR_MODULE_NOT_FOUND] line names the code you'll grep for. The url: field in the trailing object is the literal file URL Node tried to open, and it's the single most useful piece of information in the whole trace, because it tells you exactly what string the resolver ended up with after doing its own path math on your import. And the "Did you mean to import" line is Node being uncharacteristically helpful: current LTS builds will guess a fix when the miss looks like a missing extension.

Firefox and Safari don't print this one, because this isn't a language-level error. It's Node's module loader, not the JS engine, and it only exists server-side. If you're chasing a similar-sounding "module not found" in a browser console, that's a bundler error (webpack, Vite, esbuild), not this one, and the fix lives in bundler config, not here.

Two adjacent errors get confused with this one constantly, so let's separate them now. Error: Cannot find module 'x' with code: 'MODULE_NOT_FOUND' (no ERR_ prefix) is the CommonJS resolver: different algorithm, different article. ERR_UNSUPPORTED_DIR_IMPORT and ERR_PACKAGE_PATH_NOT_EXPORTED are ESM too, but they're the resolver refusing something it found, not failing to find it. All three get name-checked later in this piece so you know which one you actually have.

Six ways to trigger it yourself

Here's the smallest possible repro. ESM, Node, no dependencies.

json

// package.json
{ "name": "esm1", "version": "1.0.0", "type": "module" }

javascript

// math.js
export function add(a, b) { return a + b; }

javascript

// index.js
import { add } from './math';
console.log(add(2, 3));

bash

$ node index.js

That's the exact trace at the top of this article. One missing .js. Nothing else wrong.

Five more variants, each verified on the same Node 22.22.2 install, each throwing the identical ERR_MODULE_NOT_FOUND code with a different url: value:

bash

# typo'd path, no "did you mean" hint this time, the guess isn't close enough
import { add } from './mathz.js';

# case mismatch: file is Math.js, import says math.js, works on macOS/Windows, dies on Linux
import { mul } from './math.js';

# importing a package whose package.json "main" points at a dist file that was never built
import pkg from 'broken-pkg';   // main: "./dist/index.js", dist/ doesn't exist

# a monorepo symlink pointing at a package whose entry file got renamed post-publish

# a TypeScript build where moduleResolution: "bundler" let an extensionless
# import through tsc, and node then tries to run the compiled output directly

The last one is the one that actually cost me the better part of a day, and it's coming back in detail three sections from now.

Does your Node version even matter here?

Mostly no. That's worth saying up front so you stop suspecting an upgrade broke this.

Node lineStatus (as of writing)ESM resolver behaviorNotes
20 (Iron)EOL (nodejs.org lists its last release in March 2026)Same resolver algorithmDon't ship on this anymore regardless of this bug
22 (Jod)LTS, now in its Maintenance windowSame resolver, require(esm) on by default since 22.12.0The version every trace here came from
24 (Krypton)Active LTSSame resolverNative TypeScript type-stripping unflagged by default in this line
26Current since May 5, 2026Same resolverTemporal on by default, V8 14.6; module resolution itself unchanged

The strict, no-guessing ESM resolution algorithm has been stable since ESM shipped as non-experimental. What has moved around it is the tooling built on top: require(esm) went from experimental-flagged to on-by-default in 22.12.0, and native TypeScript type-stripping went from a flag to the default somewhere around the 22.18.0 / 24.3.0 line. I've seen that pairing referenced in the Node and ESLint issue trackers, so treat it as "that neighborhood," not a guaranteed patch number. Neither change touches how import './math' gets resolved. If you upgraded and this started happening, the upgrade didn't change the rule. It just moved you onto a code path (native TS execution, or require(esm) pulling in a differently-shaped dependency) that finally exercises the rule you were already breaking.

One-line forward note: 27 is next up as the Current release under Node's newer, calendar-aligned release cadence. Check nodejs/Release on GitHub before you plan an upgrade around it; I'm not going to guess a date here.

The file is right there — so why can't Node find it?

Because Node's ESM resolver isn't looking for a file. It's resolving a URL, using a spec-defined algorithm that does exactly what you literally wrote and not one character more.

require('./math') in CommonJS is a helpful assistant: it tries ./math, then ./math.js, then ./math.json, then ./math.node, then ./math/index.js, and hands you the first one that exists. import './math' in ESM is a strict librarian: you give it the exact call number or you get nothing. No extension guessing. No directory index fallback. No cache-and-hope. That's not a bug. It's the whole point of the ESM spec, because deterministic resolution is what lets browsers, bundlers, and Node all agree on what a given import statement means without running arbitrary filesystem probes.

I picked up this bug on a Tuesday afternoon two weeks into a new job, and it wasn't even my code. tsc built clean. The Vite dev server ran the same source fine for months. Then the Docker image for the API shipped, the container ran node dist/index.js instead of going through Vite, and the first request crashed it on an import that had never once failed in development. Nobody had touched that file in three sprints.

How the ESM resolver actually decides

Worth knowing the actual function names, because they're the ones in your stack trace and they map directly to the causes below.

moduleResolve is the entry point. It calls packageResolve when the specifier is a bare package name (import x from 'lodash'), or resolves relative and absolute specifiers directly. packageResolve reads the target package.json, and if it has an "exports" field, hands off to packageExportsResolve, which is a closed door: anything not explicitly listed in exports throws ERR_PACKAGE_PATH_NOT_EXPORTED, even if the file physically exists on disk. No exports field at all falls back to legacyMainResolve, which reads "main". This is the one that bit the broken-pkg example above: it does not verify the target file exists at resolution time the way you'd hope, it just hands the computed path to finalizeResolution, which is where the actual fs.stat happens and where ERR_MODULE_NOT_FOUND actually gets thrown for every one of these cases.

finalizeResolution is blunt on purpose. It does a real filesystem check (a stat call) against the exact resolved URL. On Linux, that stat is case-sensitive because the filesystem is case-sensitive; there's no special-casing in Node for this, it's just inheriting POSIX semantics. On macOS's default APFS and on Windows' NTFS, the same stat call succeeds against a differently-cased file, because the filesystem itself is doing case-insensitive matching underneath Node. Node isn't inconsistent here. Your filesystems are.

And this is the mechanism that makes "just add the extension" the actual fix rather than a workaround: once the specifier is a fully resolved, correctly-cased, existing path, there's nothing left for the resolver to guess, because it was never going to guess in the first place.

Two hours I spent blaming the wrong thing

First theory: package installation. A recent npm install had touched the lockfile, so I assumed something got hoisted wrong or a transitive dependency shifted. I ran npm ls broken-import-thing: clean tree, right version, nothing hoisted weird. Deleted node_modules, reinstalled from the lockfile, same crash, same url: field pointing at the same nonexistent path. That ruled out the package manager in about fifteen minutes; I just didn't believe the result and spent another twenty poking at it anyway.

Second theory: an exports map problem, because I'd been bitten by ERR_PACKAGE_PATH_NOT_EXPORTED on a different project the month before and pattern-matched too fast. I opened the target package's package.json, checked its exports field, confirmed the subpath I was importing was listed. Wrong error family entirely. ERR_PACKAGE_PATH_NOT_EXPORTED and ERR_MODULE_NOT_FOUND come from different functions in the resolver and mean different things (one refuses, one can't find), and I'd confused a two-week-old memory with the actual code in front of me.

The tell, when I finally slowed down and read the url: field instead of the message text above it: it pointed at dist/utils/format with no extension, and dist/utils/format.js existed right next to it. That's not a missing file. That's a missing three characters.

Here's the diagnostic path that actually gets you there, in order, runnable on your own copy of this:

bash

# 1. Read the url: field, not the message. It's the ground truth.
#    (in the trace itself, no command needed, just stop skimming)

# 2. Does that exact path exist?
ls -la /app/dist/utils/format.js
# exists → extension or case problem. doesn't exist → wrong path or unbuilt dist.

# 3. Watch the resolver work in real time.
NODE_DEBUG=module node dist/index.js 2>&1 | head -20
# prints lines like: MODULE 2320: looking for "./format" in ["."]
# confirms exactly what specifier the resolver received, pre-crash.

# 4. Check case, specifically, if #2 found a "different" file.
ls dist/utils/ | grep -i format
# two different-cased hits here means it'll work on your Mac and die in Linux CI.

# 5. If it's a package (not your own code), check what actually got published.
npm pack --dry-run
# lists every file npm would ship. If dist/ isn't in that list, the "files"
# field or .npmignore is eating it, and that's a broken-package problem, not yours.

One trap in that list: import.meta.resolve('./format') looks like it should answer "does this exist," and it doesn't throw even for a path that's missing an extension. I tested this directly and it happily returns a file:// URL for a target that doesn't exist on disk. It resolves the URL, not the file. Don't use it as an existence check; use ls or fs.existsSync against the url: field instead.

Ranked by how often each one was actually the cause, across every time I've hit this: missing extension on a relative import, first by a wide margin, especially now that TS build configs can let it through unflagged (more on that below). Wrong or stale path after a refactor, second. Case mismatch surfacing only in Linux CI or Docker, third, and the nastiest one, because it never reproduces on the author's machine. Broken package publish, fourth. Everything else is rare enough that you'll know it when you see it.

Four fixes, ranked by how lazy you're allowed to be

Add the extension. This is correct, not a workaround: the spec expects a fully resolved specifier, and ./math.js is that.

diff

- import { add } from './math';
+ import { add } from './math.js';

Yes, even though the source file might be .ts. TypeScript's own convention under NodeNext/node16 module resolution is to write the extension the compiled output will have, .js, even in a .ts source file. It looks wrong the first time you write it. It's correct.

Turn on the TypeScript check that would've caught this before it ever ran. Set "moduleResolution": "nodenext" (or "node16") in tsconfig.json and tsc refuses to compile an extensionless relative import:

src/index.ts(1,21): error TS2835: Relative import paths need explicit file
extensions in ECMAScript imports when '--moduleResolution' is 'node16' or
'nodenext'. Did you mean './math.js'?

That's the fix I'd actually ship. "moduleResolution": "bundler" (common in Vite/webpack-fronted projects because it matches what the bundler itself tolerates) does not raise this error. I confirmed it compiles clean and emits the broken specifier verbatim into dist/. That setting is fine if a bundler is the only thing that will ever run the output. It's a landmine the moment anything (a test runner, a CLI entry point, a container's CMD) runs the compiled JS straight through node.

Fix the package, if the broken import is someone else's. If npm pack --dry-run shows dist/ missing, the publish is broken: the "files" field or .npmignore is excluding built output. That's a bug report and a pinned-version workaround, not something you fix locally.

Fix the case, and make sure it stays fixed. Rename the file to match every import site, then check git config core.ignorecase: if it's true on your machine (common default on macOS), git itself won't warn you about a rename that only changes case, and you'll ship the same landmine again next month.

Writing imports so this can't happen again

The version of this code I'd actually commit:

typescript

// tsconfig.json: the part that matters here
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true
  }
}

typescript

// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

typescript

// src/index.ts: extension matches compiled output, not source
import { add } from './math.js';
console.log(add(2, 3));

With nodenext resolution, the extensionless version simply doesn't compile — the whole class of bug moves from "crashes in production" to "red squiggle in your editor." That's the actual design fix: stop relying on a human to remember an extension, and let the compiler refuse to build the broken version.

For plain JavaScript with no build step, node:path and explicit relative imports remove the ambiguity the same way:

javascript

// node:fs / node:path: always prefixed, always explicit, no guessing anywhere
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

If you're running TypeScript straight through Node's native type-stripping (unflagged by default on current LTS lines), one more wrinkle: the specifier has to be the literal extension on disk. node src/index.ts resolves import './math.js' by looking for a literal file named math.js. It does not know that maps to math.ts, because type-stripping isn't a build step with its own resolution rules, it's the same ESM resolver reading the file as-is. Import ./math.ts with the real extension when you're running source directly and skipping tsc entirely; only switch back to ./math.js once something actually compiles it.

Making the lint step catch it before Node does

  • tsc --noEmit with moduleResolution: nodenext in CI, even if your dev server uses a bundler with looser resolution. Catches the extensionless-import class outright, before it reaches a runtime.
  • import/extensions (eslint-plugin-import) or n/no-missing-import (eslint-plugin-n) — both flag a missing extension on relative specifiers without needing the TypeScript compiler in the loop at all, useful for plain-JS repos.
  • npm pack --dry-run as a CI step on anything you publish. It would have caught the broken-pkg example above before it ever reached a consumer's node_modules.
  • publint and arethetypeswrong for packages specifically — both check that what a package.json's exports/main field promises actually exists in the published tarball.
  • A case-sensitivity job in CI on Linux, even if every developer is on a Mac. It's the cheapest possible insurance: the bug is invisible locally and guaranteed to surface the first time someone deploys to a Linux container, which for most teams is every single deploy.
  • Run at least one CI leg on plain node dist/entry.js, not just through whatever dev server or test runner your team uses day to day. If your only CI signal is vitest or a Vite build, you can ship a resolver-breaking import and never see it fail until a real user does.

What to remember

Read the url: field before the message text. It's the resolved path Node actually tried, and it tells you in one line whether you're missing an extension, a case match, or a whole file. ESM resolution doesn't guess, ever, on purpose, and "add the extension" is the correct fix, not a workaround around a bug. moduleResolution: "bundler" in tsconfig.json will let this exact failure through tsc clean and hand it to node in production; nodenext catches it at compile time instead. Case mismatches are invisible on macOS and Windows and guaranteed on Linux, so test the failure mode your CI actually runs, not just the one your laptop does. And if the broken import belongs to a dependency, npm pack --dry-run tells you in ten seconds whether the maintainer forgot to ship dist/.

Related: the CommonJS sibling of this error (MODULE_NOT_FOUND, no ERR_ prefix) uses a completely different resolution algorithm and deserves its own article. So do ERR_UNSUPPORTED_DIR_IMPORT and ERR_PACKAGE_PATH_NOT_EXPORTED: same resolver, different failure mode, since it found something and refused it instead of never finding it at all.

nodejsesmmodulestypescriptmodule-resolutiondebuggingJavaScript

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments