>_C2CZ
Serverless · Self-Hosting · Security · Independent Ops

ERR_MODULE_NOT_FOUND: imported from (six causes, measured)

Alex Rivera covers Cloudflare Workers and the serverless edge at C2CZ. Author page · All authors.

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/srv/app/lib/db' imported from /srv/app/server.mjs is the ES module resolver telling you it will not guess a file name — and the path it prints is not a file that exists, it is the URL it tried. In CommonJS, require('./lib/db') probes .js, .json, .node and index.js for you. import probes nothing. The extension is part of the specifier, the directory has to be named explicitly, and a missing one character in the import is enough to stop a container from booting.

I hit this converting a Node service from CommonJS to ESM: the code review looked clean, node --check liked it, and the image died on its first request with a path that pointed at a file sitting right there on disk. The message is precise but it does not name the rule it is enforcing, so the fix reads as arbitrary until you see the resolver's side of it. This post is the measurement: eleven small projects, six distinct error codes, every string copied from a real run on Node v26.5.1.

Three of the six codes are new enough that most answers you will find online describe behaviour this runtime no longer has — ERR_REQUIRE_ESM in particular no longer exists for a plain ESM file, and the --experimental-specifier-resolution=node workaround does nothing at all. Both are measured below, with the control that proves the flag was really accepted.

TL;DR

  • The extension is mandatory in ESM. './lib/db' never resolves; './lib/db.mjs' always does. In CommonJS it is the other way round and both work, which is why a mechanical conversion breaks.
  • The quoted path is the resolver's probe, not a file. Cannot find module '/srv/app/lib/db' means “nothing exists at that URL” even when /srv/app/lib/db.mjs is right there.
  • Six codes, six different causes: ERR_MODULE_NOT_FOUND (missing relative file), ERR_MODULE_NOT_FOUND with Cannot find package (missing dependency), ERR_UNSUPPORTED_DIR_IMPORT, ERR_PACKAGE_PATH_NOT_EXPORTED, ERR_IMPORT_ATTRIBUTE_MISSING, ERR_REQUIRE_ASYNC_MODULE.
  • A directory import fails even when index.js is inside it. Measured with and without the file: the code is ERR_UNSUPPORTED_DIR_IMPORT in both cases; index.js only changes whether Node adds a hint.
  • The “Did you mean” hint only ever proposes a .js candidate. Four measured pairs: a .mjs or index.mjs neighbour produces no hint at all, which is why people conclude the file is missing when it is not.
  • Three popular fixes are obsolete on Node 26: the legacy resolver flag is accepted and silently does nothing, require() of an ES module now works, and ESM syntax in a typeless package is a warning that reparses instead of a SyntaxError.
  • Fail in CI, not in the container. The preflight script at the end of this post walks a project, checks every specifier against the real resolver rules, and exits non-zero on the first extensionless import.

The two facts inside the message

Read the message as two independent statements. The first path is what the resolver tried to open, built from the importing file's own directory — not from your working directory, not from the package root. The second path is the file that contains the offending import statement. Together they are a complete location: this line of this file asked for something that is not there.

The structured fields below the message carry the same information in machine-readable form, and the stack's top frame names the exact resolver step. When the top frame is finalizeResolution, the specifier was understood and the target was not found. When it is packageResolve, the bare specifier was looked up in node_modules. When it is packageExportsResolve, an exports map was found and did not allow the subpath you asked for. That one line of stack separates three entirely different investigations.

CodeMessage fragmentWhat it actually means
ERR_MODULE_NOT_FOUNDCannot find module '<path>'The resolved URL does not exist on disk: missing extension, wrong case, or a file that never made it into the image.
ERR_MODULE_NOT_FOUNDCannot find package '<name>'The bare specifier was looked up through node_modules on the way up and nowhere was it installed.
ERR_UNSUPPORTED_DIR_IMPORTDirectory import '<path>' is not supportedThe specifier points at a directory. ESM never runs index resolution for you.
ERR_PACKAGE_PATH_NOT_EXPORTEDPackage subpath '<sub>' is not defined by "exports"The dependency is installed and has an exports map that does not list the subpath you imported.
ERR_IMPORT_ATTRIBUTE_MISSINGneeds an import attribute of "type: json"A JSON module was imported without the required attribute.
ERR_REQUIRE_ASYNC_MODULErequire() cannot be used on an ESM graph with top-level awaitCommonJS is loading an ES module that awaits at the top level; the only fix is import().

Reproduce it in ninety seconds

The fixture below is the smallest project that produces the headline error, and the last two lines of the script strip the ten stack frames so the message is readable. Everything runs under /tmp and creates nothing outside it.

set -eu
DEMO=/tmp/esm-demo
rm -rf "$DEMO"
mkdir -p "$DEMO/app/lib"
cat > "$DEMO/app/package.json" <<'JSON'
{ "name": "app", "type": "module" }
JSON
cat > "$DEMO/app/lib/db.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/app/server.mjs" <<'JS'
import { query } from './lib/db';

console.log('boot ok:', query());
JS
cd "$DEMO/app"
node server.mjs > "$DEMO/err.txt" 2>&1 || true
sed '/^    at /d' "$DEMO/err.txt" | grep -v '^$'

Measured output on Node v26.5.1, with the at frames removed:

node:internal/modules/esm/resolve:272
    throw new ERR_MODULE_NOT_FOUND(
          ^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/esm-demo/app/lib/db' imported from /tmp/esm-demo/app/server.mjs
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///tmp/esm-demo/app/lib/db'
}
Node.js v26.5.1

Two details are worth pinning down before you touch anything. The url property is a file:// URL ending in lib/db — no extension, exactly what you typed. And the path is absolute and anchored to server.mjs. Launching the same file from the repository root, node app/server.mjs, prints the identical line with the identical absolute path; relative specifiers are resolved against the importing file, so changing the process working directory changes nothing. I have watched someone fix this by rewriting a cd in a Dockerfile entrypoint.

Cause 1: the extension is mandatory, and the hint will not save you

This is the cause behind most occurrences, and the misleading part is the suggestion Node sometimes adds. Build the same import twice, once with a .mjs neighbour and once with a .js neighbour, and the difference is not cosmetic.

set -eu
DEMO=/tmp/esm-hint
rm -rf "$DEMO"
mkdir -p "$DEMO/mjs/lib" "$DEMO/js/lib"
cat > "$DEMO/mjs/package.json" <<'JSON'
{ "name": "mjs", "type": "module" }
JSON
cat > "$DEMO/mjs/lib/db.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/js/package.json" <<'JSON'
{ "name": "js", "type": "module" }
JSON
cat > "$DEMO/js/lib/db.js" <<'JS'
export const query = () => 'rows: 2';
JS
for dir in mjs js; do
  printf "import { query } from './lib/db';\nconsole.log(query());\n" > "$DEMO/$dir/entry.mjs"
  ( cd "$DEMO/$dir" && node entry.mjs > "$DEMO/$dir.out" 2>&1 || true )
  echo "--- lib/db.$dir on disk, specifier './lib/db' ---"
  grep -E 'ERR_MODULE_NOT_FOUND|Did you mean' "$DEMO/$dir.out"
done

Measured:

--- lib/db.mjs on disk, specifier './lib/db' ---
    throw new ERR_MODULE_NOT_FOUND(
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/esm-hint/mjs/lib/db' imported from /tmp/esm-hint/mjs/entry.mjs
  code: 'ERR_MODULE_NOT_FOUND',
--- lib/db.js on disk, specifier './lib/db' ---
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/esm-hint/js/lib/db' imported from /tmp/esm-hint/js/entry.mjs
Did you mean to import "./lib/db.js"?
  code: 'ERR_MODULE_NOT_FOUND',

Same mistake, two different experiences. With a .js candidate on disk Node prints Did you mean to import "./lib/db.js"? and the fix is obvious; with only a .mjs candidate it prints nothing helpful, and the natural conclusion is that the file is somewhere else. It is not: the file is where you think it is, and the specifier is what is wrong. The hint machinery only considers the legacy extensions, so a modern .mjs tree gets the least useful message.

The fix is to name the file. Extensions in ESM specifiers are not style — they are the resolution.

Cause 2: directory imports, with or without index.js

The CommonJS habit of importing a directory and letting index.js answer is the second most common conversion break, and it produces a different code. Both fixtures are measured, because the presence of index.js changes the message but not the outcome.

set -eu
DEMO=/tmp/esm-dir
rm -rf "$DEMO"
mkdir -p "$DEMO/noindex/lib" "$DEMO/withindex/lib"
cat > "$DEMO/noindex/package.json" <<'JSON'
{ "name": "noindex", "type": "module" }
JSON
cat > "$DEMO/noindex/lib/db.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/withindex/package.json" <<'JSON'
{ "name": "withindex", "type": "module" }
JSON
cat > "$DEMO/withindex/lib/index.js" <<'JS'
export const query = () => 'rows: 2';
JS
for dir in noindex withindex; do
  printf "import { query } from './lib';\nconsole.log(query());\n" > "$DEMO/$dir/entry.mjs"
  ( cd "$DEMO/$dir" && node entry.mjs > "$DEMO/$dir.out" 2>&1 || true )
  echo "--- $dir: import './lib' ---"
  grep -E 'ERR_UNSUPPORTED_DIR_IMPORT|Did you mean' "$DEMO/$dir.out"
done

Measured:

--- noindex: import './lib' ---
    throw new ERR_UNSUPPORTED_DIR_IMPORT(path, basePath, String(resolved));
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/tmp/esm-dir/noindex/lib' is not supported resolving ES modules imported from /tmp/esm-dir/noindex/entry.mjs
  code: 'ERR_UNSUPPORTED_DIR_IMPORT',
--- withindex: import './lib' ---
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/tmp/esm-dir/withindex/lib' is not supported resolving ES modules imported from /tmp/esm-dir/withindex/entry.mjs
Did you mean to import "./lib/index.js"?
  code: 'ERR_UNSUPPORTED_DIR_IMPORT',

The second project has a perfectly good lib/index.js and still fails, because the resolver refuses directory specifiers before it ever looks inside. The message even names the candidate it would accept. Write './lib/index.js', or keep the directory specifier and give the directory a package.json whose exports map resolves . — which is what the fix ladder below does for a dependency-shaped tree.

Cause 3: the file is not in the image

When the resolver walks node_modules and finds nothing, the message changes shape: the word package replaces module, the absolute path disappears, and there is no url property, because no URL was ever built. This is the code that makes a service work on a laptop and die in a slim production image, since npm ci --omit=dev removes everything the runtime now imports.

set -u
DEMO=/tmp/esm-bare
rm -rf "$DEMO"
mkdir -p "$DEMO/bare" "$DEMO/klass/lib"
cat > "$DEMO/bare/package.json" <<'JSON'
{ "name": "bare", "type": "module" }
JSON
cat > "$DEMO/bare/index.mjs" <<'JS'
import pg from 'pg';

console.log(typeof pg.Pool);
JS
cat > "$DEMO/klass/package.json" <<'JSON'
{ "name": "klass", "type": "module" }
JSON
cat > "$DEMO/klass/lib/db.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/klass/index.mjs" <<'JS'
import { query } from './Lib/db.mjs';

console.log(query());
JS
( cd "$DEMO/bare" && node index.mjs > "$DEMO/bare.out" 2>&1 || true )
( cd "$DEMO/klass" && node index.mjs > "$DEMO/klass.out" 2>&1 || true )
echo "--- dependency in devDependencies, pruned in the image ---"
grep -E 'ERR_MODULE_NOT_FOUND|Cannot find package' "$DEMO/bare.out"
echo "--- imported as ./Lib/db.mjs, on disk as ./lib/db.mjs (macOS to Linux) ---"
grep -E 'ERR_MODULE_NOT_FOUND' "$DEMO/klass.out"

Measured — two projects, two causes, one script:

--- dependency in devDependencies, pruned in the image ---
  throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'pg' imported from /tmp/esm-bare/bare/index.mjs
  code: 'ERR_MODULE_NOT_FOUND'
--- imported as ./Lib/db.mjs, on disk as ./lib/db.mjs (macOS to Linux) ---
    throw new ERR_MODULE_NOT_FOUND(
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/esm-bare/klass/Lib/db.mjs' imported from /tmp/esm-bare/klass/index.mjs
  code: 'ERR_MODULE_NOT_FOUND',

The package is genuinely absent

Cannot find package 'pg' is a package-manager answer, not a code answer. Check three things in this order: is the dependency in dependencies rather than devDependencies; does the build stage copy the relevant source directory into the runtime stage; and does .dockerignore exclude something the runtime needs. The measurement here is local, so the dependency is simply not installed — in a container the same string appears after a successful build, which is what makes it confusing.

Two packaging notes that cost me an afternoon each. A multi-stage build that installs with npm ci --omit=dev and then copies node_modules from the builder will silently omit anything the builder only had because a test imported it — the ESM resolver does not care that it was present during the build. And exports maps interact with this: a dependency can be installed and still unusable, which is the next code in this post. This host has no Docker daemon (docker ps answers Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?), so treat the image-layer description as documented behaviour of the runtime and the strings above as measured.

The case does not match

./Lib/db.mjs against an on-disk ./lib/db.mjs is the trap that only fires after a move to Linux. The resolver builds the URL from the specifier you wrote, the filesystem on a case-sensitive mount says no such file, and the error text looks identical to a genuinely missing file — except that the path in the message preserves your capitalisation, which is the tell. Compare the two paths character by character before you believe the file is missing. Note also that this shape gets no Did you mean hint even though the correct file is one directory walk away.

The fix is to correct the specifier. If you are converting a codebase that has ever been developed on a case-insensitive filesystem, grep every import for a capital letter in a path segment and confirm each one against ls — the preflight script at the end of this post reports these as a distinct line for exactly that reason.

Cause 4: the exports map forbids that subpath

The most confusing of the six, because the file exists, the package is installed, and the path you typed is a real path inside it. An exports map turns the package's file layout into a private implementation detail: only the subpaths listed are importable, and everything else is refused by design.

set -eu
DEMO=/tmp/esm-exports
rm -rf "$DEMO"
mkdir -p "$DEMO/node_modules/subpkg/internal" "$DEMO/consumer"
cat > "$DEMO/node_modules/subpkg/package.json" <<'JSON'
{
  "name": "subpkg",
  "version": "1.0.0",
  "type": "module",
  "exports": { ".": "./index.js" }
}
JSON
cat > "$DEMO/node_modules/subpkg/index.js" <<'JS'
export const start = () => 'subpkg ready';
JS
cat > "$DEMO/node_modules/subpkg/internal/pool.js" <<'JS'
export const pool = () => 'raw pool';
JS
cat > "$DEMO/consumer/package.json" <<'JSON'
{ "name": "consumer", "type": "module" }
JSON
cat > "$DEMO/consumer/app.mjs" <<'JS'
import { pool } from 'subpkg/internal/pool.js';

console.log(pool());
JS
cd "$DEMO/consumer"
node app.mjs > "$DEMO/app.out" 2>&1 || true
grep -E 'ERR_PACKAGE_PATH_NOT_EXPORTED' "$DEMO/app.out"

Measured:

  return new ERR_PACKAGE_PATH_NOT_EXPORTED(
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './internal/pool.js' is not defined by "exports" in /tmp/esm-exports/node_modules/subpkg/package.json imported from /tmp/esm-exports/consumer/app.mjs
  code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'

Two readings of that message are possible and you have to know which one applies. If the package is yours, the map is incomplete and you add the subpath to it. If the package is a third party's, the refusal is intentional — the maintainer marked internal/ private and the supported entry point is whatever exports does list, usually the bare package name. Reaching into another package's internal/ is the thing being prevented, and pinning an older version to keep doing it just moves the breakage to the next upgrade.

The same code appears for a package with exports and no subpath pattern at all, which is common in freshly published ESM packages: "exports": { ".": "./index.js" } and nothing else means the deep-import escapes everyone relied on under CommonJS are gone.

Cause 5: JSON modules need an import attribute

The assertion syntax changed from assert to with, and the failure is a TypeError rather than an Error, which is why grepping logs for Error can miss it entirely.

set -eu
DEMO=/tmp/esm-json
rm -rf "$DEMO"
mkdir -p "$DEMO"
cat > "$DEMO/package.json" <<'JSON'
{ "name": "cfg", "type": "module" }
JSON
cat > "$DEMO/config.json" <<'JSON'
{ "region": "eu-west-3" }
JSON
cat > "$DEMO/index.mjs" <<'JS'
import cfg from './config.json';

console.log(cfg.region);
JS
cd "$DEMO"
node index.mjs > "$DEMO/out.txt" 2>&1 || true
grep -E 'ERR_IMPORT_ATTRIBUTE_MISSING' "$DEMO/out.txt"

Measured:

        throw new ERR_IMPORT_ATTRIBUTE_MISSING(url, 'type', validType);
TypeError [ERR_IMPORT_ATTRIBUTE_MISSING]: Module "file:///tmp/esm-json/config.json" needs an import attribute of "type: json"
  code: 'ERR_IMPORT_ATTRIBUTE_MISSING'

The fix is one clause: import cfg from './config.json' with { type: 'json' }. A dynamic import takes it as an options object instead — await import('./config.json', { with: { type: 'json' } }) — and the old assert keyword is a hard error now, not a deprecated alias. If you own the code, note that a static JSON import is inlined into the module graph and cannot be swapped at runtime; when the configuration has to come from the environment, read the file with node:fs after parsing process.env, and skip the attribute question entirely.

The fix ladder

Apply the cheapest change first and re-run the failing entry point after each one. These three run against the fixture from the first section, so start there.

set -eu
DEMO=/tmp/esm-demo
cd "$DEMO/app"
cat > server.mjs <<'JS'
import { query } from './lib/db.mjs';

console.log('boot ok:', query());
JS
echo "--- fix 1: name the file ---"
node server.mjs
cat > package.json <<'JSON'
{
  "name": "app",
  "type": "module",
  "imports": { "#db": "./lib/db.mjs" }
}
JSON
cat > alias.mjs <<'JS'
import { query } from '#db';

console.log('alias ok:', query());
JS
echo "--- fix 2: a stable private alias ---"
node alias.mjs
cat > lazy.mjs <<'JS'
const { query } = await import('./lib/db.mjs');

console.log('dynamic ok:', query());
JS
echo "--- fix 3: dynamic import() ---"
node lazy.mjs

Measured:

--- fix 1: name the file ---
boot ok: rows: 2
--- fix 2: a stable private alias ---
alias ok: rows: 2
--- fix 3: dynamic import() ---
dynamic ok: rows: 2

Fix 1 is the correct answer for application code and the one you should ship. Fix 2 is the answer I now prefer for anything with more than a handful of internal imports: the imports field in package.json maps a #-prefixed specifier to a concrete file, so modules import '#db' and the physical path appears exactly once in the repository. Renaming or moving the file then becomes a one-line change instead of a mechanical edit through every importer, and the alias works from node, from test runners and from a bundler without extra configuration.

Fix 3 is for the case where the target is genuinely optional — a plugin directory, a platform-specific adapter — and the point is to wrap it in a try/catch so the missing module is a decision rather than a crash. Reaching for dynamic import just to dodge the extension rule buys you nothing: the specifier still has to name the file, as the same error code in the previous sections shows.

The dependency-shaped variant of the same ladder is a published exports map. This is the fixture from the exports section, patched, and the consumer updated to the supported subpath.

set -u
EXPORTS=/tmp/esm-exports
BARE=/tmp/esm-bare
cat > "$EXPORTS/node_modules/subpkg/package.json" <<'JSON'
{
  "name": "subpkg",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./pool": "./internal/pool.js"
  }
}
JSON
cat > "$EXPORTS/consumer/app.mjs" <<'JS'
import { pool } from 'subpkg/pool';

console.log('exports fix:', pool());
JS
cat > "$BARE/klass/index.mjs" <<'JS'
import { query } from './lib/db.mjs';

console.log('case fix:', query());
JS
( cd "$EXPORTS/consumer" && node app.mjs )
( cd "$BARE/klass" && node index.mjs )

Measured, both lines in one run:

exports fix: raw pool
case fix: rows: 2

Note what the exports map buys: the deep path ./internal/pool.js stays private, and the supported entry point is the short, stable subpkg/pool. That is a deliberate trade of flexibility for the freedom to refactor — which is why the third-party case is not a bug to report upstream.

Ask the resolver before the runtime does

import.meta.resolve() exposes the resolver to your own code, and its behaviour is worth a warning of its own: for relative specifiers it returns a URL without checking that anything exists, so it is not a validity test. For bare specifiers it throws. Measured against the fixture:

cd /tmp/esm-demo/app
cat > resolve.mjs <<'JS'
import { existsSync } from 'node:fs';

for (const spec of ['./lib/db.mjs', './lib/db', './lib', 'node:fs', 'pg']) {
  try {
    const url = import.meta.resolve(spec);
    const onDisk = url.startsWith('file:') ? existsSync(new URL(url)) : true;
    console.log(`import.meta.resolve(${JSON.stringify(spec)}) -> ${url} exists=${onDisk}`);
  } catch (err) {
    console.log(`import.meta.resolve(${JSON.stringify(spec)}) -> ${err.code ?? err.name}: ${String(err.message).split('\n')[0]}`);
  }
}
JS
node resolve.mjs

Measured — note the second line, where a nonexistent file resolves happily:

import.meta.resolve("./lib/db.mjs") -> file:///tmp/esm-demo/app/lib/db.mjs exists=true
import.meta.resolve("./lib/db") -> file:///tmp/esm-demo/app/lib/db exists=false
import.meta.resolve("./lib") -> file:///tmp/esm-demo/app/lib exists=true
import.meta.resolve("node:fs") -> node:fs exists=true
import.meta.resolve("pg") -> ERR_MODULE_NOT_FOUND: Cannot find package 'pg' imported from /tmp/esm-demo/app/resolve.mjs

So the honest pattern is resolve plus an existence check, which is exactly what the preflight script below does. Used that way it is a useful diagnostic in a health endpoint: a route that resolves every specifier the service depends on and reports the failures costs one file and tells you at deploy time what would otherwise surface as a 500 on the first request that touches a cold module.

Three fixes that no longer do what they say

All three of these were correct advice for Node 18 and are wrong on Node 26. The last line of the script is the control that matters: --totally-bogus-flag is rejected outright by the same binary, which proves the legacy flag below was accepted and simply had no effect.

set -u
DEMO=/tmp/esm-obsolete
rm -rf "$DEMO"
mkdir -p "$DEMO/broken/lib" "$DEMO/typeless" "$DEMO/cjs"
cat > "$DEMO/broken/package.json" <<'JSON'
{ "name": "broken", "type": "module" }
JSON
cat > "$DEMO/broken/lib/db.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/broken/server.mjs" <<'JS'
import { query } from './lib/db';

console.log(query());
JS
cat > "$DEMO/typeless/package.json" <<'JSON'
{ "name": "typeless" }
JSON
cat > "$DEMO/typeless/index.js" <<'JS'
import { readFile } from 'node:fs/promises';

console.log('import syntax ran fine:', typeof readFile);
JS
cat > "$DEMO/cjs/package.json" <<'JSON'
{ "name": "cjs", "type": "commonjs" }
JSON
cat > "$DEMO/cjs/lib.mjs" <<'JS'
export const query = () => 'rows: 2';
JS
cat > "$DEMO/cjs/lib-async.mjs" <<'JS'
const cfg = await Promise.resolve({ region: 'eu-west-3' });

export const region = cfg.region;
JS
cat > "$DEMO/cjs/start.cjs" <<'JS'
const m = require('./lib.mjs');

console.log('require(esm) ok:', m.query());
JS
cat > "$DEMO/cjs/start-tla.cjs" <<'JS'
const m = require('./lib-async.mjs');

console.log(m.region);
JS
echo "--- legacy resolver flag on a genuinely broken project ---"
( cd "$DEMO/broken" && node --experimental-specifier-resolution=node server.mjs > "$DEMO/flag.out" 2>&1 || true )
grep -E 'ERR_MODULE_NOT_FOUND|bad option|Warning' "$DEMO/flag.out" || true
echo "--- control: node does reject a flag that does not exist ---"
( cd "$DEMO/broken" && node --totally-bogus-flag server.mjs > "$DEMO/bogus.out" 2>&1 || true )
grep -E 'bad option' "$DEMO/bogus.out" || true
echo "--- ESM syntax in a package without a type field ---"
( cd "$DEMO/typeless" && node index.js > "$DEMO/typeless.out" 2>&1 || true )
grep -E 'Warning|Reparsing|eliminate this warning' "$DEMO/typeless.out" || true
echo "--- require() of an ES module from CommonJS ---"
( cd "$DEMO/cjs" && node start.cjs ) || true
echo "--- require() of an ES module that awaits at top level ---"
( cd "$DEMO/cjs" && node start-tla.cjs > "$DEMO/tla.out" 2>&1 || true )
grep -E 'ERR_REQUIRE_ASYNC_MODULE|Required module|Use import' "$DEMO/tla.out" || true

Measured:

--- legacy resolver flag on a genuinely broken project ---
    throw new ERR_MODULE_NOT_FOUND(
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/esm-obsolete/broken/lib/db' imported from /tmp/esm-obsolete/broken/server.mjs
  code: 'ERR_MODULE_NOT_FOUND',
--- control: node does reject a flag that does not exist ---
node: bad option: --totally-bogus-flag
--- ESM syntax in a package without a type field ---
(node:1503166) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///tmp/esm-obsolete/typeless/index.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /tmp/esm-obsolete/typeless/package.json.
--- require() of an ES module from CommonJS ---
require(esm) ok: rows: 2
--- require() of an ES module that awaits at top level ---
    throw new ERR_REQUIRE_ASYNC_MODULE(filename, parent, locations);
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.
Required module: /tmp/esm-obsolete/cjs/lib-async.mjs
  code: 'ERR_REQUIRE_ASYNC_MODULE'

Obsolete fix 1: --experimental-specifier-resolution=node. It restores extensionless resolution only on the Node releases that documented it. Here the flag is accepted with no warning, does not appear in node --help, and leaves the extensionless import failing with the same ERR_MODULE_NOT_FOUND. A flag that is silently ignored is worse than a flag that is rejected, because a Dockerfile carrying it looks like a considered decision. The control proves the binary does reject unknown flags, so this is not a typo being swallowed.

Obsolete fix 2: ERR_REQUIRE_ESM. require() of an ES module works now, and the fixture prints require(esm) ok: rows: 2. The one remaining refusal is top-level await: a CommonJS file cannot synchronously load a module whose body has to wait, and the error names the module so you can find it. The remedy is await import('./lib-async.mjs') from an async caller — not a flag.

Obsolete fix 3: SyntaxError: Cannot use import statement outside a module. On this runtime an import statement in a .js file whose package has no type field is a warning, not an error: the file is reparsed as an ES module and runs. The strings to grep for are MODULE_TYPELESS_PACKAGE_JSON and Reparsing as ES module (the pid in parentheses is your own process id, so it differs on every run). It is still a problem worth fixing — the warning says the reparse costs time on every cold start — but you will not find it by searching logs for a SyntaxError that never appears.

A preflight check that fails the build instead of the container

The four failure modes that survive a successful build are all statically visible, so they belong in CI. This script walks a project, extracts every specifier, and applies the real resolution rules without executing the code: it never runs your imports, so it is safe on a build server. It is written with no dependencies and prints one line per problem, which keeps the CI log short enough to read.

#!/usr/bin/env node
/**
 * check-esm-specifiers.mjs — fail the build before Node fails at runtime.
 *
 * Scope: the specifier shapes that break at runtime and are statically visible.
 *   - relative or absolute specifier without a file extension   (ERR_MODULE_NOT_FOUND)
 *   - specifier that points at a directory                      (ERR_UNSUPPORTED_DIR_IMPORT)
 *   - specifier whose file is missing, or whose on-disk case differs (Linux/CI only)
 *   - bare package that is not installed (a dependency pruned from the image)
 *   - package that imports itself by name without an "exports" field
 *   - "#subpath" import whose declared target is a missing file
 *   - nearest package.json that is not valid JSON, or a module file it cannot read
 *
 * Deliberately out of scope, and skipped rather than guessed:
 *   - "exports"/"imports" condition objects, pattern keys and array fallbacks
 *   - whether a package's "exports" map allows a given subpath
 *   - an "imports" target that is a bare package name (it resolves through node_modules)
 *   - JSON modules and their import attributes: the syntax itself moved from
 *     `assert { type: "json" }` to `with { type: "json" }`, so a static text check
 *     cannot judge it without pinning a Node major
 * Those need the resolver's condition set. A checker that guesses reports failures on
 * projects that boot fine. Answer them with import.meta.resolve(), which runs the real
 * resolver, rather than with `node --check`, which only parses and never resolves.
 *
 * Resolution rules are chosen by the SYNTAX, not by the file extension: a require()
 * call always follows CommonJS rules (createRequire re-enables them inside an ES
 * module) and an import always follows ESM rules, even in a .cjs file.
 *
 * Usage: node check-esm-specifiers.mjs [project-root]
 * Exit:  0 = every specifier resolves, 1 = at least one problem, 2 = bad usage.
 */
import { readdir, readFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { basename, dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { builtinModules } from 'node:module';

const SPECIFIER_RE =
  /(?:(\brequire)\s*\(\s*|\bfrom\s*|\bimport\s*\(\s*|\bimport\s*)['"]([^'"\n]+)['"]/g;
const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]);
const SKIP_DIRS = new Set(['node_modules', '.git']);
// A URL scheme ("node:", "file:", "data:") instead of a path or a package name.
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;

const root = resolve(process.argv[2] ?? process.cwd());
try {
  if (!(await stat(root)).isDirectory()) throw new Error('not a directory');
} catch (err) {
  console.error(`usage: node check-esm-specifiers.mjs <project-root>  (${err.message})`);
  process.exit(2);
}

async function* walk(dir, depth = 0) {
  if (depth > 12) return;
  let entries;
  try {
    entries = await readdir(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
    const full = resolve(dir, entry.name);
    if (entry.isDirectory()) {
      if (!SKIP_DIRS.has(entry.name)) yield* walk(full, depth + 1);
    } else if (/\.(mjs|cjs|js)$/.test(entry.name)) {
      yield full;
    }
  }
}

/** package name of a bare specifier: '@scope/pkg/sub' -> '@scope/pkg' */
function packageNameOf(spec) {
  const parts = spec.split('/');
  return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
}

function installedPackageDir(fromDir, name) {
  let dir = fromDir;
  for (;;) {
    if (existsSync(resolve(dir, 'node_modules', name, 'package.json'))) {
      return resolve(dir, 'node_modules', name);
    }
    const parent = dirname(dir);
    if (parent === dir) return null;
    dir = parent;
  }
}

/**
 * Nearest package.json plus the directory that declares it: "imports" targets are
 * resolved against the declaring package.json, not against the importer.
 */
async function nearestPackage(fromDir) {
  let dir = fromDir;
  for (;;) {
    const candidate = resolve(dir, 'package.json');
    if (existsSync(candidate)) {
      try {
        return { dir, json: JSON.parse(await readFile(candidate, 'utf8')), broken: false };
      } catch {
        return { dir, json: {}, broken: true };
      }
    }
    const parent = dirname(dir);
    if (parent === dir) return { dir: null, json: {}, broken: false };
    dir = parent;
  }
}

/** require() resolves extensionless paths, directories and .js/.json/.node */
function resolvesAsCommonJS(pathspec, fromDir) {
  const base = isAbsolute(pathspec) ? '/' : fromDir;
  const candidates = [pathspec, `${pathspec}.js`, `${pathspec}.json`, `${pathspec}.node`,
                      `${pathspec}/index.js`, `${pathspec}/index.cjs`];
  return candidates.some((c) => existsSync(resolve(base, c)));
}

/**
 * True when the only reason `target` is missing is a letter case difference, anywhere
 * along the path — not just in the last segment. macOS and Windows let `./Lib/db.mjs`
 * sit on a disk that holds `./lib/db.mjs`, and Linux then refuses to resolve it, which
 * is a different and much more confusing failure than a file that is simply absent.
 */
async function isCaseMismatch(target) {
  const parts = target.split('/').filter(Boolean);
  let cur = target.startsWith('/') ? '/' : '.';
  for (const part of parts) {
    const names = await readdir(cur).catch(() => null);
    if (!names) return false;
    if (names.includes(part)) {
      cur = cur === '/' ? `/${part}` : `${cur === '.' ? '' : cur}/${part}`;
      continue;
    }
    return names.some((n) => n.toLowerCase() === part.toLowerCase());
  }
  return false;
}

const problems = [];
const brokenReported = new Set();   // one line per broken package.json, not one per importer
let scanned = 0;

for await (const file of walk(root)) {
  let source;
  try {
    source = await readFile(file, 'utf8');
  } catch (err) {
    // A dangling symlink or an unreadable file must be a reported problem, never an
    // unhandled ENOENT that kills the whole scan before it prints anything.
    problems.push(`${file}  cannot be read (${err.code ?? err.name})`);
    continue;
  }
  const fromDir = dirname(file);
  const pkg = await nearestPackage(fromDir);
  if (pkg.broken && pkg.dir && !brokenReported.has(pkg.dir)) {
    brokenReported.add(pkg.dir);
    problems.push(`${resolve(pkg.dir, 'package.json')}  not valid JSON`);
  }

  for (const match of source.matchAll(SPECIFIER_RE)) {
    const [, requireKeyword, spec] = match;
    scanned += 1;
    const line = source.slice(0, match.index).split('\n').length;

    if (BUILTINS.has(spec)) continue;

    if (spec.startsWith('#')) {
      const declared = pkg.json.imports;
      const target = declared && typeof declared === 'object' ? declared[spec] : undefined;
      if (typeof target !== 'string') continue;      // condition objects and patterns
      // A bare package name is a legal "imports" target and resolves through
      // node_modules; only filesystem targets can be checked for existence here.
      if (!/^\.{0,2}\//.test(target) && !SCHEME_RE.test(target)) continue;
      const resolved = target.startsWith('file:')
        ? fileURLToPath(target)
        : resolve(pkg.dir ?? fromDir, target);
      if (!existsSync(resolved)) {
        problems.push(`${file}:${line}  ${spec}  imports map points at a missing file (${target})`);
      }
      continue;
    }

    // A URL scheme is not a path and not a package name. "file:" is a real path on
    // disk; every other scheme (data:, https:, bun:, ...) resolves outside the
    // filesystem, so it is skipped rather than judged.
    let pathspec = spec;
    if (SCHEME_RE.test(spec)) {
      if (!spec.startsWith('file:')) continue;
      try {
        pathspec = fileURLToPath(spec);
      } catch {
        continue;
      }
    }
    // A ?query or #fragment suffix is not part of the path on disk.
    pathspec = pathspec.replace(/[?#].*$/, '');

    const relative = pathspec.startsWith('.');
    if (!relative && !isAbsolute(pathspec)) {
      const name = packageNameOf(pathspec);
      if (name === pkg.json.name) {
        // Self-reference by name is legal ONLY when the package declares "exports";
        // without it Node throws ERR_MODULE_NOT_FOUND.
        if (pkg.json.exports !== undefined) continue;
        problems.push(`${file}:${line}  ${spec}  self-reference without an "exports" field in ${resolve(pkg.dir ?? fromDir, 'package.json')}`);
        continue;
      }
      if (!installedPackageDir(fromDir, name)) {
        problems.push(`${file}:${line}  ${spec}  package "${name}" is not installed`);
      }
      continue;
    }

    if (requireKeyword) {
      // require() keeps CommonJS resolution even inside an ES module (createRequire).
      if (!resolvesAsCommonJS(pathspec, fromDir)) {
        problems.push(`${file}:${line}  ${spec}  file does not exist`);
      }
      continue;
    }

    // Everything else is ESM: import always follows ESM resolution, even in a .cjs file.
    const target = isAbsolute(pathspec) ? pathspec : resolve(fromDir, pathspec);
    if (existsSync(target) && (await stat(target)).isDirectory()) {
      problems.push(`${file}:${line}  ${spec}  directory import (name ./${spec.split('/').pop()}/index.js explicitly)`);
      continue;
    }
    if (!/\.[cm]?js$|\.json$/.test(basename(pathspec))) {
      problems.push(`${file}:${line}  ${spec}  relative specifier without a file extension`);
      continue;
    }
    if (!existsSync(target)) {
      const hint = (await isCaseMismatch(target)) ? ' (case mismatch: Linux is case-sensitive)' : '';
      problems.push(`${file}:${line}  ${spec}  file does not exist${hint}`);
    }
  }
}

for (const problem of problems.sort()) console.log(`FAIL ${problem}`);
console.log(`${problems.length === 0 ? 'OK  ' : ''}${scanned} specifier(s) checked under ${root}, ${problems.length} problem(s)`);
process.exit(problems.length === 0 ? 0 : 1);

Save it as check-esm-specifiers.mjs, then run it against a service that has the extensionless import from the first section, before and after the one-character fix:

set -u
DEMO=/tmp/esm-check
CHK=${1:-./check-esm-specifiers.mjs}
test -f "$CHK" || { echo "save the script above as $CHK first"; exit 2; }
rm -rf "$DEMO"
mkdir -p "$DEMO/service/lib"
cat > "$DEMO/service/package.json" <<'JSON'
{ "name": "service", "type": "module" }
JSON
cat > "$DEMO/service/lib/db.mjs" <<'JS'
export const query = async () => ['ada', 'grace'];
JS
cat > "$DEMO/service/server.mjs" <<'JS'
import { createServer } from 'node:http';
import { query } from './lib/db';

createServer(async (req, res) => {
  const rows = await query();
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ rows }));
}).listen(0, '127.0.0.1');
JS
echo "--- before ---"
node "$CHK" "$DEMO/service" || true
cat > "$DEMO/service/package.json" <<'JSON'
{
  "name": "service",
  "type": "module",
  "imports": { "#db": "./lib/db.mjs" }
}
JSON
cat > "$DEMO/service/server.mjs" <<'JS'
import { createServer } from 'node:http';
import { query } from '#db';

createServer(async (req, res) => {
  const rows = await query();
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ rows }));
}).listen(0, '127.0.0.1');
JS
echo "--- after ---"
node "$CHK" "$DEMO/service" || true

Measured, exit status 1 then 0:

--- before ---
FAIL /tmp/esm-check/service/server.mjs:2  ./lib/db  relative specifier without a file extension
2 specifier(s) checked under /tmp/esm-check/service, 1 problem(s)
--- after ---
OK  2 specifier(s) checked under /tmp/esm-check/service, 0 problem(s)

The checker stays inside what a static pass can answer — specifier shapes, files on disk, installed packages. It deliberately does not interpret exports or imports condition objects, * pattern keys or array fallbacks: a checker that guesses at the resolver's condition set reports failures on projects that boot fine, so those entries are skipped rather than judged, and import.meta.resolve() on the entry file is the answer for them. It does see side-effect imports (import './side.mjs', import 'ghostpkg') as well as named ones. That last point matters: a checker that only matches from clauses reports zero problems on a file whose first line imports a package that is not installed.

Wire it into the pipeline before the image build, and it costs one process start instead of one container restart. The reporting deliberately stays flat — file:line specifier reason — so a failing CI run is readable without a log viewer, and the exit codes are distinct enough to gate a deploy on 1 while treating 2 as a configuration mistake in the pipeline itself.

Troubleshooting table

String in the logFirst thing to checkFix
Cannot find module '<path>', top frame finalizeResolutionDoes the specifier end in .js, .mjs or .json?Name the file explicitly.
Cannot find package '<name>', top frame packageResolvedependencies vs devDependencies, and what the runtime stage actually copies.Install it in the image, or move the import behind a runtime guard.
Directory import ... is not supportedIs the last path segment a directory rather than a file?Import './lib/index.js', or map . in that directory's exports.
Package subpath ... is not defined by "exports"Is it your package or a third party's?Add the subpath to your exports; for a third party, import the documented entry point.
needs an import attribute of "type: json"Is the specifier a .json file?Add with { type: 'json' }, or read it with node:fs.
require() cannot be used on an ESM graph with top-level awaitDoes the required module await at the top level?Use await import() from an async caller.
MODULE_TYPELESS_PACKAGE_JSON warningIs type missing from package.json?Set "type": "module" (or rename to .mjs) and stop paying the reparse.

Checklist for a CommonJS to ESM conversion

  • Every relative specifier names a file with its extension — no directory imports, no bare index expectations.
  • "type": "module" is set in every package that contains ESM syntax, so nothing relies on the typeless reparse.
  • Internal paths that get renamed live behind imports aliases rather than being repeated in every importer.
  • Every dependency imported by runtime code is in dependencies, and the runtime stage of the image really receives it.
  • Path casing matches the files on disk, checked on a case-sensitive filesystem.
  • Directory exports maps list every subpath consumers are expected to import.
  • The preflight script runs in CI before the image build, and its failure blocks the deploy.
  • For related deployment failures, see Workers: No such module "node:buffer" (compat date fix) for the runtime-side version of a module that is not available, and ECONNREFUSED from another host: the 127.0.0.1 bind trap for the class of error where a service is up and still unreachable.

Last verified: 2026-09-26 — every command, error string and version in this article was executed on this host immediately before publishing (Node v26.5.1, Linux 6.17 aarch64, Python 3.13.5). The six error codes and their exact wording come from the runs reproduced above, with the at stack frames stripped for legibility where stated. The container image-layer paragraphs are labelled as documented behaviour: this host has no Docker daemon, so no container was built. The process id in the MODULE_TYPELESS_PACKAGE_JSON warning is the reviewer's own and will differ in your logs.

C2CZ

Hyper-specific engineering guides: Cloudflare Workers, Turso, self-hosting, network security, offensive security, independent operations.