Node.js Module Resolution & ESM/CJS Interop
Parent: JavaScript and Node.js · researched 2026-06-02T18:02:36.400Z· 7 sources · 8 concepts · skill nodejs-module-resolution
This reference is about HOW Node.js turns a specifier into a loaded module — the two
Overview
- This reference is about HOW Node.js turns a specifier into a loaded module - the two [source]
- resolution algorithms (CommonJS require and ESM) and the interop seam between them. It [source]
- assumes you already know what a module is and how to write one; that intro is owned by [source]
- the javascript-nodejs reference. Two other siblings own adjacent layers and are [source]
- explicitly out of scope here: [source]
- nodejs-typescript-and-runtime-features - TypeScript native type-stripping, the [source]
- --experimental-strip-types / --experimental-transform-types flags, and the [source]
- .ts/.mts import-extension rules. This file covers JS/JSON/Wasm resolution only. [source]
- Bundlers/transpilers (esbuild, webpack, Vite, tsc's moduleResolution) - they [source]
- reimplement resolution with their own rules. This file is the Node runtime resolver. [source]
- The two mental models you must hold separately: **CommonJS resolution is synchronous, [source]
- filesystem-probing, and extension-tolerant** (require('./util') tries util, util.js, [source]
- util.json, util.node, then util/index.js…). **ESM resolution is URL-based, mostly [source]
- specifier-exact, and requires file extensions** (import './util.js' - no extension [source]
- guessing, no directory index). The "exports"/"imports" package.json fields, the [source]
- condition system, and the dual-package hazard are the shared machinery that both algorithms [source]
- now route through, and require(esm) (stable since the v20.19/v22.12 LTS lines) is the [source]
- bridge that finally lets CommonJS load ES modules synchronously. [source]
1. The CommonJS `require(X)` resolution algorithm
- require(X) from a module at path Y runs a fixed, synchronous sequence (the [source]
- spec-pseudocode names are load-bearing - they appear in errors and docs): [source]
- Core / node: builtin → return it and STOP. The node: prefix always hits the [source]
- builtin and bypasses require.cache. [source]
- /, ./, ../ (relative/absolute) → LOAD_AS_FILE(Y+X) then LOAD_AS_DIRECTORY(Y+X). [source]
- #-prefixed → LOAD_PACKAGE_IMPORTS (private internal specifiers, concept 4). [source]
- LOAD_PACKAGE_SELF (self-reference by package name), then [source]
- LOAD_NODE_MODULES(X, dirname(Y)) - the node_modules walk. [source]
- Else THROW MODULE_NOT_FOUND. [source]
- LOAD_AS_FILE(X) probes extensions in order: X (verbatim) → X.js → X.json → [source]
- X.node (native addon). The .js case consults the closest package.json "type" [source]
- to decide ESM vs CJS (and otherwise detects module syntax). [source]
- LOAD_INDEX(X) probes X/index.js → X/index.json → X/index.node. [source]
- LOAD_AS_DIRECTORY(X) reads X/package.json's "main", runs LOAD_AS_FILE then [source]
- LOAD_INDEX on it, and falls back to LOAD_INDEX(X). [source]
- LOAD_NODE_MODULES(X, START) iterates NODE_MODULES_PATHS(START), trying [source]
- LOAD_PACKAGE_EXPORTS then LOAD_AS_FILE then LOAD_AS_DIRECTORY in each dir. [source]
- NODE_MODULES_PATHS(START) generates the walk: append node_modules at every [source]
- ancestor directory up to the filesystem root, then GLOBAL_FOLDERS. So [source]
- /home/ry/projects/foo.js requiring bar searches [source]
- /home/ry/projects/node_modules/bar → /home/ry/node_modules/bar → [source]
- /home/node_modules/bar → /node_modules/bar. This array is exposed as module.paths. [source]
- require.cache keys loaded modules by resolved filename (delete a key to force reload). [source]
- require.resolve(req[, {paths}]) runs the machinery without loading; require.resolve.paths(req) [source]
- returns the search list (or null for a core module). require.main is the entry module — [source]
- require.main === module is the CJS "am I the entry point?" idiom. NODE_PATH (colon-/ [source]
- semicolon-delimited absolute paths) is a legacy prepend to the walk; prefer "exports" over it. [source]
2. The ESM resolution algorithm
- ESM resolution is URL-based and specified as ESM_RESOLVE(specifier, parentURL) → { format, resolved }: [source]
- Valid URL → parse and reserialize. [source]
- /, ./, ../ → resolve relative to parentURL (a file: URL). [source]
- #… → PACKAGE_IMPORTS_RESOLVE. [source]
- Bare specifier → PACKAGE_RESOLVE: if it's a builtin, return node: + name; else walk [source]
- node_modules, read package.json, and if "exports" exists call [source]
- PACKAGE_EXPORTS_RESOLVE, else resolve "main"/subpath directly. [source]
- For file: URLs: reject percent-encoded / or \; **throw ERR_UNSUPPORTED_DIR_IMPORT [source]
- for a directory (no index lookup); throw ERR_MODULE_NOT_FOUND if absent**; then set [source]
- format via ESM_FILE_FORMAT. [source]
- PACKAGE_EXPORTS_RESOLVE and PACKAGE_TARGET_RESOLVE evaluate the "exports" map: [source]
- target objects are walked in insertion order, returning the first key that is "default" [source]
- or present in the active condition set; arrays try each entry; null blocks. ESM_FILE_FORMAT [source]
- maps extension → format: .mjs→module, .cjs→commonjs, .json→json, .wasm→wasm, [source]
- .js→"type"-driven (or syntax-detected), no-extension→"type" or detection. [source]
- The consequences that bite developers: file extensions are mandatory (import './x' [source]
- fails - use './x.js'), directory indexes don't work (import './lib' fails - use [source]
- './lib/index.js'), and a package with "exports" is encapsulated (concept 3). [source]
3. `package.json` `"exports"` — conditional exports, subpaths, patterns, encapsulation
- "exports" is the modern public-API surface for a package. Three powers: [source]
- Conditional exports - map the same specifier to different files by environment. The [source]
- condition keys, in the documented most-specific-to-least order: "types" (MUST be [source]
- first, for type systems), "node-addons" (Node with native addons; off under [source]
- --no-addons), "node" (any Node), "import" (loaded via import/import()), [source]
- "require" (loaded via require()), "module-sync" (via import/import()/require() — [source]
- a synchronous ESM with no top-level await), "default" (MUST be last). **Key order is [source]
- significant** - the resolver returns the first match, so an "import" listed after [source]
- "default" is dead. "import" and "require" are mutually exclusive at resolve time. [source]
- Custom community conditions are matched via node --conditions=<name> (-C). [source]
- Subpath exports - expose specific deep entry points: `{ ".": "./index.js", [source]
- "./feature": "./src/feature.js" }. Subpath patterns use *` as a **flexible string [source]
- substitution (NOT a glob)**: "./features/.js": "./src/features/.js" maps [source]
- pkg/features/x.js → ./src/features/x.js, and * spans /. Map a target to null to [source]
- block a private subtree ("./features/internal/*": null). [source]
- Encapsulation - once "exports" exists, only listed subpaths are importable; [source]
- everything else throws ERR_PACKAGE_PATH_NOT_EXPORTED. Add `"./package.json": [source]
- "./package.json"` if consumers need it. (Encapsulation is not "strong" - an absolute path [source]
- require('/abs/node_modules/pkg/secret.js') still works.) Exports sugar: when only [source]
- "." exists, "exports": "./index.js" is shorthand for { ".": "./index.js" }. [source]
4. `package.json` `"imports"` — private `#` internal specifiers
- "imports" defines specifiers only resolvable from inside the same package. Keys MUST [source]
- start with # (to disambiguate from bare external specifiers). Targets can be **internal [source]
- files OR external packages, and support the same conditions and * patterns** as [source]
- Then import dep from '#dep' / import x from '#internal/util.js' resolve per condition. [source]
- Unlisted # specifiers throw ERR_PACKAGE_IMPORT_NOT_DEFINED. This is the standard [source]
- replacement for fragile ../../.. relative paths and for swapping implementations by env. [source]
5. The dual-package hazard
- When one package ships both a CJS and an ESM build (via import/require conditions), an [source]
- app can end up loading both copies - once through each entry. The hazard: [source]
- Two instances of the module exist simultaneously; module-level state diverges [source]
- (caches, registries, singletons are not shared). [source]
- instanceof breaks - a class from the ESM copy is not the same identity as the class [source]
- from the CJS copy, so x instanceof Pkg.Thing fails across the seam. [source]
- Two documented cures: (a) ESM-first with a thin CJS wrapper - author in ESM and make the [source]
- "require" target a .cjs that does module.exports = require('./index.js') (now viable [source]
- because require(esm) works, concept 6); or **(b) isolate all stateful logic into a single [source]
- CJS file** that both the ESM and CJS entry points load (the ESM entry uses createRequire), [source]
- so there is exactly one state object. With require(esm) unflagged, shipping a **single ESM [source]
- build** consumable by both import and require is increasingly the simplest answer. [source]
6. ESM ↔ CJS interop: `require(esm)`, importing CJS, `createRequire`
- require(esm) - synchronous require of ES modules. Timeline: added behind [source]
- --experimental-require-module in v22.0.0 (backported to v20.17), **unflagged/default [source]
- in v23 and across the LTS lines (v20.19.0+, v22.12.0+**), now marked stable. With it, [source]
- require() of an ES module no longer throws ERR_REQUIRE_ESM. Constraint: the target [source]
- must be unambiguously ESM (.mjs or "type":"module") and fully synchronous - a [source]
- top-level await anywhere in its graph throws ERR_REQUIRE_ASYNC_MODULE. Disable with [source]
- --no-experimental-require-module. The returned object is the module namespace: the ESM [source]
- default is on .default, and (v23+) a 'module.exports' key mirrors the CJS-interop view. [source]
- Importing CommonJS from ESM. module.exports is exposed as the default export; [source]
- Node additionally runs cjs-module-lexer to statically detect named exports so [source]
- import { name } from './cjs.cjs' works. Detection is a heuristic - dynamically [source]
- assigned or computed exports are not seen; fall back to the default import and destructure. [source]
- module.createRequire(filename) builds a require scoped to an ESM file: [source]
- const require = createRequire(import.meta.url) - the standard way to pull a CJS-only [source]
- package (or JSON) into ESM. (Requires created this way are not affected by async hooks.) [source]
- import.meta (ESM only): import.meta.url (the module's file: URL); [source]
- import.meta.resolve(specifier) - synchronous since v20 (returns a URL string, not [source]
- a Promise; honors "exports"); import.meta.dirname and import.meta.filename (stable [source]
- v22/v24; the ESM equivalents of __dirname/__filename, file: modules only); [source]
- import.meta.main (newer) ≈ require.main === module. [source]
7. Module customization (loader) hooks
- Node lets you intercept resolution and loading with hooks, registered before app code via [source]
- --import ./register-hooks.js: [source]
- module.registerHooks({ resolve, load }) (v23.5+, release candidate) - **synchronous, [source]
- in-thread** hooks. The recommended default: simpler, no inter-thread overhead, and works [source]
- cleanly for CommonJS in the graph. Returns { deregister() }. Registration is LIFO — [source]
- the last-registered hook runs first, then chains toward Node's default. [source]
- module.register(specifier[, parentURL][, options]) (v20.6+) - registers a hooks [source]
- module that runs asynchronously on a separate loader thread. Use it when a hook must do [source]
- async work and you want Node to own the worker/atomics plumbing; options.data + [source]
- options.transferList (e.g. a MessagePort) pass data to initialize. (It carries [source]
- documentation-only deprecation DEP0205 steering most users to registerHooks, but it is [source]
- not runtime-deprecated and remains the off-thread API.) [source]
- The hooks. initialize(data) runs once at registration. **`resolve(specifier, context, [source]
- nextResolve)** receives context.{conditions, importAttributes, parentURL}` and returns [source]
- { url, format?, importAttributes?, shortCircuit? }. load(url, context, nextLoad) [source]
- returns { format, source, shortCircuit? } where format ∈ `'builtin' | 'commonjs' | [source]
- 'json' | 'module' | 'wasm'` (+ addon/typescript variants). Each hook must either call [source]
- next…() (to chain) or set shortCircuit: true. [source]
- History. The old --experimental-loader ./loader.mjs flag (v8.8) was the original API; [source]
- its getFormat/getSource/transformSource/globalPreload hooks were removed in v16.12 [source]
- and the whole flag superseded by register/registerHooks. module.builtinModules, [source]
- module.isBuiltin(name), and module.syncBuiltinESMExports() round out the introspection. [source]
8. Import attributes, JSON modules, and import maps
- Import attributes - import data from './x.json' with { type: 'json' } (and the dynamic [source]
- import('./x.json', { with: { type: 'json' } })). No longer experimental (v20.18/v22.12+). [source]
- They replaced the older assert { type: ... } "import assertions" syntax (deprecated). [source]
- JSON modules require with { type: 'json' }, expose only a default export (no named [source]
- exports), and share a cache entry with the CJS JSON cache. [source]
- data: and node: imports - data:text/javascript,… / data:application/json,… (no [source]
- relative resolution) and node:fs builtins. [source]
- Import maps are a browser/HTML standard (<script type="importmap">) for remapping [source]
- bare specifiers in the browser; Node has no built-in import-map support - the Node [source]
- equivalent of "remap a bare specifier" is "imports" (concept 4) or a resolve hook. [source]
Methodology — practical patterns
- Authoring a package's public API: lead with "exports". List every supported entry [source]
- point; rely on encapsulation to keep deep imports private; put "types" first and [source]
- "default" last in every condition object. [source]
- Ship dual packages only when forced. Prefer a single ESM build now that require(esm) [source]
- is unflagged. If you must ship both, use the ESM-source + .cjs-wrapper pattern, or [source]
- isolate state into one shared CJS file - never let both builds carry independent state. [source]
- Pull CJS-only deps / JSON into ESM with createRequire rather than fighting named-export [source]
- detection; pull pure data with with { type: 'json' }. [source]
- Use "imports" (#…) for internal aliases and env-swapped implementations instead of [source]
- ../../.. chains or build-time aliasing. [source]
- Prefer module.registerHooks (in-thread) for transforms/instrumentation; reach for [source]
- module.register (off-thread) only when a hook genuinely needs async I/O. [source]
- Register hooks via --import, not inside app code, so they affect the entry module and [source]
Anti-patterns
- Relying on extensionless / directory imports in ESM. import './util' and [source]
- import './lib' fail - ESM needs './util.js' and './lib/index.js'. Only CJS guesses. [source]
- Mis-ordering conditions. Putting "default" (or "require") before "import" makes [source]
- the later, more specific branch unreachable - the first match wins. [source]
- Forgetting that "exports" blocks deep imports. Adding "exports" silently breaks [source]
- pkg/lib/internal.js consumers with ERR_PACKAGE_PATH_NOT_EXPORTED; list (or deliberately [source]
- withhold) every subpath, and re-add "./package.json" if needed. [source]
- A dual package with shared mutable state in both builds → divergent singletons and [source]
- instanceof failures (the dual-package hazard). [source]
- require()-ing an ESM with top-level await → ERR_REQUIRE_ASYNC_MODULE; use dynamic [source]
- import(), or remove the top-level await. [source]
- **Treating * in "exports" as a glob.** It is a plain string substitution; ./* exposes [source]
- everything, including dotfiles, unless narrowed or blocked with null. [source]
- assert { type: 'json' } - the deprecated assertion syntax; use with { type: 'json' }. [source]
Troubleshooting
- ERR_MODULE_NOT_FOUND → ESM couldn't find the file: URL: missing extension, wrong [source]
- relative base, or a bare specifier not exported. Check the exact specifier string; [source]
- import.meta.resolve shows what Node computes. [source]
- ERR_REQUIRE_ESM → you're on an old Node (or --no-experimental-require-module), or the [source]
- target isn't unambiguously ESM. Upgrade to a current LTS, or use dynamic import(). [source]
- ERR_REQUIRE_ASYNC_MODULE → the required ESM (or a transitive dep) uses top-level await. [source]
- ERR_PACKAGE_PATH_NOT_EXPORTED → the subpath isn't in the dependency's "exports"; use [source]
- a listed entry point, or (last resort) an absolute path past node_modules. [source]
- ERR_UNSUPPORTED_DIR_IMPORT → ESM import of a directory; point at the index file. [source]
- ERR_PACKAGE_IMPORT_NOT_DEFINED → a #… specifier with no "imports" entry (or no [source]
- Named import from a CJS module is undefined → cjs-module-lexer couldn't statically [source]
- see it (dynamic/computed exports); import the default and destructure at runtime. [source]
- A loader hook isn't applied to the entry file → register it with --import (preload), [source]
- not from within application code, which runs too late. [source]
References
- Node.js - Modules: CommonJS modules (require(X), LOAD_AS_FILE/LOAD_INDEX/LOAD_AS_DIRECTORY/LOAD_NODE_MODULES/NODE_MODULES_PATHS, LOAD_PACKAGE_EXPORTS/LOAD_PACKAGE_IMPORTS, require.cache/require.resolve, NODE_PATH): https://nodejs.org/api/modules.html [source]
- Node.js - Modules: ECMAScript modules (specifiers, mandatory extensions, import.meta.*, CJS interop, require(esm), import attributes, ESM_RESOLVE/PACKAGE_RESOLVE/PACKAGE_EXPORTS_RESOLVE/PACKAGE_TARGET_RESOLVE/ESM_FILE_FORMAT): https://nodejs.org/api/esm.html [source]
- Node.js - Modules: Packages ("type", "exports" conditional/subpath/pattern/encapsulation, "imports", the dual-package hazard, conditions & --conditions): https://nodejs.org/api/packages.html [source]
- Node.js - Modules: node:module API (module.register, module.registerHooks, resolve/load/initialize hooks, createRequire, builtinModules/isBuiltin, --import): https://nodejs.org/api/module.html [source]
- Node.js - Deprecations (DEP0205 module.register(), documentation-only): https://nodejs.org/api/deprecations.html [source]
- Joyee Cheung - "require(esm) in Node.js: from experiment to stability" (flag timeline, ERR_REQUIRE_ASYNC_MODULE, sync-graph constraint): https://joyeecheung.github.io/blog/2025/12/30/require-esm-in-node-js-from-experiment-to-stability/ [source]
- Node.js v23.0.0 release notes (require(esm) unflagged by default): https://nodejs.org/en/blog/release/v23.0.0 [source]
Children
- The CommonJS require(X) resolution algorithm (frontier)
- The ESM resolution algorithm (ESM_RESOLVE) (frontier)
- package.json exports — conditional exports, subpaths, patterns, encapsulation (frontier)
- package.json imports — private #internal specifiers (frontier)
- The dual-package hazard (frontier)
- ESM <-> CJS interop: require(esm), importing CJS, createRequire (frontier)
- Module customization (loader) hooks (module.register / registerHooks) (frontier)
- Import attributes, JSON modules, and import maps (frontier)
Frontier under this node: ESM <-> CJS interop: require(esm), importing CJS, createRequire, Import attributes, JSON modules, and import maps, Module customization (loader) hooks (module.register / registerHooks), The CommonJS require(X) resolution algorithm, The ESM resolution algorithm (ESM_RESOLVE), The dual-package hazard, package.json exports — conditional exports, subpaths, patterns, encapsulation, package.json imports — private #internal specifiers