Skip to content

Module Resolution

When you write import 'express' or require('express'), Node follows a deterministic resolution algorithm to locate the actual file on disk. Understanding this algorithm helps you debug missing-module errors, understand why subpath imports work, and reason about how ESM and CJS packages interoperate.

A bare specifier is a module name that does not start with ./, ../, or / — for example 'express' or 'lodash/fp'. Node resolves bare specifiers by walking up the directory tree from the importing file, checking each node_modules/ folder along the way until it finds a match or reaches the filesystem root.

Terminal window
# Given a file at /project/src/app.js importing 'express',
# Node checks these locations in order:
/project/src/node_modules/express
/project/node_modules/express found here
/node_modules/express

This traversal is what allows nested packages to carry their own dependency versions without conflicting with siblings.

Node resolves specifiers to concrete files using these rules:

  • .js — treated as CJS by default, or ESM when the nearest package.json has "type": "module".
  • .mjs — always ESM, regardless of package.json.
  • .cjs — always CJS, regardless of package.json.

ESM enforces explicit extensions in specifiers. Unlike CJS, you cannot omit the extension and rely on Node to guess:

// CJS — extension optional (Node tries .js, .json, index.js …)
const utils = require('./utils');
// ESM — extension required
import utils from './utils.js';

The two module systems can coexist, but with constraints:

// ── CJS file (utils.cjs) ─────────────────────────────────────
// CJS can require other CJS modules normally.
const fs = require('fs');
module.exports = { readConfig: () => fs.readFileSync('.env', 'utf8') };
// Since Node 22.12 (and 20.19 LTS), CJS CAN require() an ESM module:
// const esm = require('./modern.mjs'); // ✅ (throws ERR_REQUIRE_ASYNC_MODULE only if it uses top-level await)
// ── ESM file (app.mjs) ──────────────────────────────────────
// ESM CAN import a CJS module; it receives module.exports as the default.
import cjsUtils from './utils.cjs'; // ✅ default = module.exports object
// ESM can also import another ESM module normally.
import { helper } from './helpers.mjs'; // ✅

Historically CJS could not require() an ESM module — it threw ERR_REQUIRE_ESM — because ESM loading is static and asynchronous while CJS loading is synchronous. Since Node 22.12 (and 20.19 LTS), require() of an ESM module works by default, as long as that module has no top-level await. A module that uses top-level await still throws ERR_REQUIRE_ASYNC_MODULE, because a synchronous require() call cannot wait for an async evaluation.

The "exports" field in package.json is the authoritative way for a package to declare its public API surface. Any path not listed in "exports" is private — consumers cannot import it directly.

{
"name": "my-lib",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}

With this map:

  • import 'my-lib' resolves to dist/index.mjs.
  • require('my-lib') resolves to dist/index.cjs.
  • import 'my-lib/utils' resolves to dist/utils.mjs.
  • import 'my-lib/internal/secret' throws — "./internal/secret" is not exported.

You can pipe ESM source directly to Node without a file on disk:

Terminal window
echo "import os from 'node:os'; console.log(os.platform());" | node --input-type=module

This is useful for quick experiments or CI one-liners that need ESM semantics without touching the project’s package.json.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

When Node resolves a bare specifier like `"express"`, where does it look first?
Why must ESM specifiers include explicit file extensions?
Which statement about ESM/CJS interop is correct?