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.

flowchart TD
  A["Specifier string"] --> B{"Starts with './' , '../' , or '/' ?"}
  B -->|"yes (relative/absolute)"| C{"File extension?"}
  C -->|".mjs"| D["Always ESM"]
  C -->|".cjs"| E["Always CJS"]
  C -->|".js or none"| F["Check nearest package.json 'type'"]
  B -->|"no (bare specifier)"| G["Walk up node_modules/ from the importing file"]
  G --> H{"Package found with 'exports' field?"}
  H -->|"yes"| I["Match subpath against exports map, first matching condition wins"]
  H -->|"no"| J["Fall back to 'main', then index.js"]
  I --> K["Resolved file"]
  J --> K
  F --> K
  D --> K
  E --> K
Node's specifier resolution algorithm

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:

// @skip-verify multi-file: illustrative
// 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:

// @skip-verify multi-file: illustrative
// ── 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 practical consequence for library authors: you no longer need to ship both CJS and ESM builds. Publishing ESM only works on every supported Node line (22, 24, 26), because CJS consumers can require() it. The dual export map in the next section still helps older tooling, but it is no longer mandatory.

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.

Inside a conditions object, Node checks keys in the order they are written and uses the first one it recognises — it does not pick the “most specific” or “best” match. If two conditions could both apply, whichever is listed first wins, so a careless reordering silently changes what a package resolves to. When a "types" condition is present (for TypeScript consumers), it must be listed first in every conditions object — Node’s own resolver ignores it, but listing it after "import"/"require" risks a resolver that does not special-case "types" matching the wrong file first.

Terminal window
# scratch package: exports = { ".": { "import": "./esm.mjs", "default": "./cjs.cjs" } }
$ node -e "import('order-demo').then(m => console.log(m.via))"
import-wins
# Reorder to { "default": "./cjs.cjs", "import": "./esm.mjs" } — same import() call:
$ node -e "import('order-demo').then(m => console.log(m.via))"
undefined # "default" matched first, so import() got the CJS build instead

"exports" controls what outside consumers can reach; "imports" is the equivalent map for a package’s own internal aliases, always spelled with a leading # so they can never collide with a bare specifier from node_modules. It is a clean replacement for fragile relative paths like ../../../shared/utils.js scattered across a codebase.

A package can also import itself by its own name. This works only because "exports" is present — self-reference is not available on a package with no "exports" field — and it means library code can import { helper } from 'my-lib' internally, exercising the exact same public entry point its consumers use, instead of reaching into internal relative paths.

Node.js
Output (verified on Node 24)
HELLO DANA!
HELLO SAM! / HELLO SAM!

#utils inside index.js never touches node_modules lookup at all — it resolves straight through the package’s own "imports" map. The self-reference import (from 'demo-imports') goes through the exact same "exports" map an external consumer would use, which is why greet and selfGreet produce identical output: they are, literally, the same exported function.

An "exports" map that points "require" and "import" at two separate files is a common shape for shipping both CJS and ESM builds. The hazard: if those two files each define their own copy of a class (or any other stateful singleton) instead of sharing one source of truth, consumers who reach the package through both conditions end up with two unrelated implementations that only look identical.

Node.js
Output (verified on Node 24)
cjsInstance instanceof CjsMoney: true
cjsInstance instanceof esmMod.Money: false
esmInstance instanceof CjsMoney: false
esmInstance instanceof esmMod.Money: true

require() always resolves through the "require" condition and import() always resolves through the "import" condition, regardless of which module system the caller itself uses — that part is not the hazard. The hazard is that index.cjs and index.mjs each declare their own Money class. One part of an application using require('demo-dual') and another part using import('demo-dual') end up holding instances that fail every instanceof check against each other, despite both claiming to be “the same package’s Money”. This is exactly the failure mode the TypeScript course’s build and emit lesson covers from the compiler side — emitting genuinely separate CJS and ESM outputs from one source file, rather than one build both conditions point at, is what avoids it.

NODE_PATH — and why you should not reach for it

Section titled “NODE_PATH — and why you should not reach for it”

NODE_PATH is an environment variable that adds extra directories to Node’s module search path, letting a bare specifier resolve outside the normal node_modules walk. It still works today, but it predates the resolution algorithm this page describes and creates problems the exports/imports maps were designed to avoid:

  • It is not part of the resolved file’s identity. The exact same codebase resolves differently depending on an environment variable set outside the project — git clone and npm install are no longer sufficient to reproduce how imports resolve.
  • It silently shadows real dependency resolution. A module found via NODE_PATH is not recorded anywhere in package.json or the lockfile, so nothing documents that the dependency exists.
  • It does not compose with "exports". Conditional exports, subpath restrictions, and self-reference all assume the standard node_modules walk; NODE_PATH bypasses that walk entirely.

The portable replacement for “I want a shortcut path to some internal module” is the "imports" map covered above — it is checked into package.json, versioned with the code, and works identically for every developer and every CI run without any environment setup.

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.

Resolution is a lookup algorithm applied fresh at every specifier, not a cache of “where things are.” Node never asks “where did I last find express” — for a bare specifier it walks up from the current importing file’s own location through every node_modules on the way to the root, and for a package with "exports", it walks a conditions object top-to-bottom taking the first match. Two files ten directories apart can resolve the same bare specifier to two entirely different installed versions, and that is not a bug — it is the mechanism that lets nested dependencies avoid version conflicts.

ChoiceBenefitCost
Define "exports" with strict subpathsLocks down the public API; refactor internals freely without breaking consumersAnything not listed becomes instantly unreachable — a real breaking change for anyone relying on an undocumented internal path
Ship both "require" and "import" conditions from separate filesSupports CJS and ESM consumers from one packageDual-package hazard: any shared state or class defined separately in each file diverges across the two module graphs
Use "imports" / #alias for internal pathsVersioned, refactor-safe, no relative-path spaghettiOne more map to keep in sync as files move
Reach for NODE_PATHZero config changes to package.jsonNon-reproducible across machines/CI; invisible to anyone reading the dependency tree
  • “Node picks the ‘best’ or ‘most specific’ matching condition in an exports map.” It does not rank conditions at all — it takes the first key it recognises, in the order the object is written. A more “correct-looking” condition listed second never gets a chance.
  • “Requiring and importing the same package always gives you the same object.” Only if the "exports" map points both conditions at the same file (or the same file re-exported). Point them at two different files and you get two independent module instances — the dual-package hazard.
  • “NODE_PATH is just an older, equivalent way to add search paths.” It bypasses the entire "exports"/"imports" resolution model and is invisible to anything that reads package.json or the lockfile — it is not a drop-in equivalent, it is an escape hatch with real reproducibility costs.

Generated code that ships a package with both a CJS and an ESM build frequently reuses the same class or module-level state pattern in each build without noticing that they are, resolution-wise, two entirely separate files. The bug only appears once a consumer’s dependency graph loads the package through both conditions at once — invisible in a single-entry-point test.

🐛 Spot the bug

{
"name": "state-store",
"version": "1.0.0",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
}
Show the answer

"types" must be first in the conditions object, not third. Node’s own resolver ignores "types" (it only matters to TypeScript tooling), but a resolver that does not special-case "types" — including some editor integrations — will match whichever condition comes first, here "import", and silently serve the wrong declaration file or none at all. "default" is correctly last (it is meant as the catch-all), but the fix for "types" is to move it to the very first key: { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.cjs" }.

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?
A consumer runs require('this-pkg') (the "require" condition is not listed at all). Which file resolves?
{
  "exports": {
    ".": { "import": "./esm.mjs", "default": "./cjs.cjs" }
  }
}
A package's exports map has "require" pointing to index.cjs and "import" pointing to index.mjs — two separate files, each defining its own class Widget. What happens when one part of an app uses require() and another uses import() on the same package, then compares a Widget from each with instanceof?