Node.js Module Resolution & ESM/CJS Interop

Node.js Module Resolution & ESM/CJS Interop

Overview

This reference is about HOW Node.js turns a specifier into a loaded module — the two resolution algorithms (CommonJS require and ESM) and the interop seam between them. It assumes you already know what a module is and how to write one; that intro is owned by the javascript-nodejs reference. Two other siblings own adjacent layers and are explicitly out of scope here:

The two mental models you must hold separately: CommonJS resolution is synchronous, filesystem-probing, and extension-tolerant (require('./util') tries util, util.js, util.json, util.node, then util/index.js…). ESM resolution is URL-based, mostly specifier-exact, and requires file extensions (import './util.js' — no extension guessing, no directory index). The "exports"/"imports" package.json fields, the condition system, and the dual-package hazard are the shared machinery that both algorithms now route through, and require(esm) (stable since the v20.19/v22.12 LTS lines) is the bridge that finally lets CommonJS load ES modules synchronously.

Core concepts

1. The CommonJS require(X) resolution algorithm

require(X) from a module at path Y runs a fixed, synchronous sequence (the spec-pseudocode names are load-bearing — they appear in errors and docs):

  1. Core / node: builtin → return it and STOP. The node: prefix always hits the builtin and bypasses require.cache.
  2. /, ./, ../ (relative/absolute) → LOAD_AS_FILE(Y+X) then LOAD_AS_DIRECTORY(Y+X).
  3. #-prefixedLOAD_PACKAGE_IMPORTS (private internal specifiers, concept 4).
  4. LOAD_PACKAGE_SELF (self-reference by package name), then
  5. LOAD_NODE_MODULES(X, dirname(Y)) — the node_modules walk.
  6. Else THROW MODULE_NOT_FOUND.

The sub-routines:

require.cache keys loaded modules by resolved filename (delete a key to force reload). require.resolve(req[, {paths}]) runs the machinery without loading; require.resolve.paths(req) returns the search list (or null for a core module). require.main is the entry module — require.main === module is the CJS “am I the entry point?” idiom. NODE_PATH (colon-/ semicolon-delimited absolute paths) is a legacy prepend to the walk; prefer "exports" over it.

2. The ESM resolution algorithm

ESM resolution is URL-based and specified as ESM_RESOLVE(specifier, parentURL){ format, resolved }:

  1. Valid URL → parse and reserialize.
  2. /, ./, ../ → resolve relative to parentURL (a file: URL).
  3. #…PACKAGE_IMPORTS_RESOLVE.
  4. Bare specifier → PACKAGE_RESOLVE: if it’s a builtin, return node: + name; else walk node_modules, read package.json, and if "exports" exists call PACKAGE_EXPORTS_RESOLVE, else resolve "main"/subpath directly.
  5. For file: URLs: reject percent-encoded / or \; throw ERR_UNSUPPORTED_DIR_IMPORT for a directory (no index lookup); throw ERR_MODULE_NOT_FOUND if absent; then set format via ESM_FILE_FORMAT.

PACKAGE_EXPORTS_RESOLVE and PACKAGE_TARGET_RESOLVE evaluate the "exports" map: target objects are walked in insertion order, returning the first key that is "default" or present in the active condition set; arrays try each entry; null blocks. ESM_FILE_FORMAT maps extension → format: .mjsmodule, .cjscommonjs, .jsonjson, .wasmwasm, .js"type"-driven (or syntax-detected), no-extension→"type" or detection.

The consequences that bite developers: file extensions are mandatory (import './x' fails — use './x.js'), directory indexes don’t work (import './lib' fails — use './lib/index.js'), and a package with "exports" is encapsulated (concept 3).

3. package.json "exports" — conditional exports, subpaths, patterns, encapsulation

"exports" is the modern public-API surface for a package. Three powers:

4. package.json "imports" — private # internal specifiers

"imports" defines specifiers only resolvable from inside the same package. Keys MUST start with # (to disambiguate from bare external specifiers). Targets can be internal files OR external packages, and support the same conditions and * patterns as "exports":

{ "imports": { "#dep": { "node": "dep-node-native", "default": "./dep-polyfill.js" },
               "#internal/*.js": "./src/internal/*.js" } }

Then import dep from '#dep' / import x from '#internal/util.js' resolve per condition. Unlisted # specifiers throw ERR_PACKAGE_IMPORT_NOT_DEFINED. This is the standard replacement for fragile ../../.. relative paths and for swapping implementations by env.

5. The dual-package hazard

When one package ships both a CJS and an ESM build (via import/require conditions), an app can end up loading both copies — once through each entry. The hazard:

Two documented cures: (a) ESM-first with a thin CJS wrapper — author in ESM and make the "require" target a .cjs that does module.exports = require('./index.js') (now viable because require(esm) works, concept 6); or (b) isolate all stateful logic into a single CJS file that both the ESM and CJS entry points load (the ESM entry uses createRequire), so there is exactly one state object. With require(esm) unflagged, shipping a single ESM build consumable by both import and require is increasingly the simplest answer.

6. ESM ↔ CJS interop: require(esm), importing CJS, createRequire

7. Module customization (loader) hooks

Node lets you intercept resolution and loading with hooks, registered before app code via --import ./register-hooks.js:

8. Import attributes, JSON modules, and import maps

Key APIs

API / flag Surface Use it for
require.resolve(req[, {paths}]) / .paths(req) CJS Resolve without loading; inspect the search path.
require.cache CJS Inspect/evict the module cache (keyed by resolved filename).
module.createRequire(import.meta.url) ESM→CJS Get a require inside an ES module.
import.meta.resolve(spec) ESM Synchronous specifier → URL string (honors "exports").
import.meta.dirname / .filename / .url ESM ESM replacements for __dirname/__filename.
module.register(spec, parentURL, {data, transferList}) hooks Async, off-thread resolve/load hooks.
module.registerHooks({resolve, load}) hooks Sync, in-thread hooks (recommended; returns deregister).
module.isBuiltin(name) / module.builtinModules introspection Detect/enumerate core modules.
node --conditions=<name> (-C) CLI Activate a custom export/import condition.
node --import ./hooks.js app.js CLI Preload hook registration before app code (inherited by workers).
--experimental-require-module / --no-experimental-require-module CLI Toggle require(esm) (default on in current/LTS).

Methodology — practical patterns

  1. Authoring a package’s public API: lead with "exports". List every supported entry point; rely on encapsulation to keep deep imports private; put "types" first and "default" last in every condition object.
  2. Ship dual packages only when forced. Prefer a single ESM build now that require(esm) is unflagged. If you must ship both, use the ESM-source + .cjs-wrapper pattern, or isolate state into one shared CJS file — never let both builds carry independent state.
  3. Pull CJS-only deps / JSON into ESM with createRequire rather than fighting named-export detection; pull pure data with with { type: 'json' }.
  4. Use "imports" (#…) for internal aliases and env-swapped implementations instead of ../../.. chains or build-time aliasing.
  5. Prefer module.registerHooks (in-thread) for transforms/instrumentation; reach for module.register (off-thread) only when a hook genuinely needs async I/O.
  6. Register hooks via --import, not inside app code, so they affect the entry module and worker threads too.

Anti-patterns

Troubleshooting

References