This guide covers the two JavaScript module execution models — CommonJS and ECMAScript Modules — their resolution mechanics, the failure modes that arise when both coexist, and the package.json configuration required to publish safely to both. It is written for library authors and platform engineers who distribute npm packages and need deterministic behavior across Node.js versions, bundlers, and TypeScript consumers.


CJS vs ESM Resolution Flow with Dual-Package Hazard A flow diagram illustrating how Node.js routes require() calls through the CJS cache and import statements through the ESM cache, both passing through the package.json exports field. The center zone highlights the dual-package hazard where two separate instances can be created for the same package. Consumer Node.js Runtime Package require('my-lib') .cjs / .js consumer import 'my-lib' .mjs / type:module consumer CJS Module Cache require.cache exports field condition routing types → import → require ESM Module Cache import.meta cache Dual-Package Hazard Zone two caches = two instances dist/index.cjs require condition dist/index.mjs import condition dist/index.d.ts types condition Arrow direction = resolution path at runtime

Quick-Reference: Key Terms

Term Definition Reference
CommonJS (CJS) Synchronous require()-based module system; default in Node.js without "type": "module" ESM vs CJS formats
ECMAScript Modules (ESM) Static import/export syntax; enables tree-shaking and asynchronous loading ESM vs CJS formats
Dual-package hazard Two separate module instances when the same package is loaded via both require() and import Navigating the dual-package hazard
exports field package.json key that replaces main/module with explicit condition-based routing Mastering the exports field
Conditional exports Sub-keys inside exports ("import", "require", "browser", "types") that route by environment Mastering the exports field
moduleResolution TypeScript compiler option that controls how import paths are resolved (node16, bundler, etc.) Browser vs Node.js resolution
Tree-shaking Dead-code elimination performed by bundlers on statically-analyzable ESM graphs Tree-shaking & bundle optimization
Provenance attestation Cryptographic record linking a published tarball to the exact CI workflow that built it Publishing workflows

How Node.js Chooses a Module Format

Node.js determines the module format of every file before executing it. The decision is made once, at load time, and cannot change mid-execution. Two signals drive it:

File extension takes the highest precedence. A .mjs file is always ESM; a .cjs file is always CJS. The .js extension is ambiguous — Node resolves it by walking up the directory tree to find the nearest package.json and reading its "type" field.

The "type" field in package.json applies to every .js file within that package’s scope. "type": "module" makes .js files ESM; omitting it (or writing "type": "commonjs") keeps them CJS.

// package.json — affects all .js files in this directory tree
{
  "name": "my-lib",
  "type": "module"
}

This matters most during dual-format builds where a single source tree compiles into both .mjs and .cjs outputs: the "type" field governs the .js fallback but .mjs/.cjs extensions override it unconditionally.

The CJS and ESM Caching Models

The two module systems maintain entirely separate internal caches inside the same Node.js process. Understanding the difference explains why shared state breaks across the format boundary.

CJS cache (require.cache): When require('my-lib') is called for the first time, Node.js evaluates the module body synchronously, captures the result in require.cache, and returns it on every subsequent call. The cache key is the resolved absolute file path. Two different require() calls to the same path return the exact same object reference.

ESM cache: ESM maintains its own module registry, keyed by the module URL (including the file:// scheme). The registry is populated during the asynchronous link phase before any code runs. import statements pointing to a .cjs file cause Node to load it through the CJS evaluator and wrap the result — but it still enters the ESM registry as a distinct entry.

// cjs-consumer.cjs
const libA = require('my-lib');
libA.counter = 1;

// esm-consumer.mjs
import libB from 'my-lib';
console.log(libB.counter); // undefined — separate instance

This divergence is the root cause of the dual-package hazard. A singleton Map, event emitter, or global registry lives in the cache of whichever format first loaded it; the other format sees a fresh, empty copy.

Static vs Dynamic Module Analysis

The most consequential architectural difference between the two systems is not syntax — it is when the dependency graph is resolved.

CJS is evaluated dynamically. require() can appear inside if blocks, loops, or factory functions. The resolved module path can be a runtime string. This makes static analysis impossible: a bundler cannot know which exports are used without executing the code first.

ESM is analyzed statically. import and export declarations are resolved during parsing, before any code runs. Module specifiers must be string literals. Bundlers traverse the full import graph at build time and can mark unused exports as dead code — a prerequisite for tree-shaking to work.

// CJS: dynamic — bundler cannot statically trace this
const mod = process.env.DEBUG ? require('./debug') : require('./prod');

// ESM: static — bundler sees both imports at parse time and can eliminate unused ones
import { debug } from './debug.js';
import { prod } from './prod.js';

The practical implication: if your library ships only CJS, consumers using Webpack, Rollup, or Vite cannot reliably eliminate unused exports. Shipping an ESM output alongside CJS is the minimal requirement for enabling dead-code elimination in downstream bundles.

Hazard and Failure-Mode Inventory

ERR_PACKAGE_PATH_NOT_EXPORTED

HAZARD PREVENTION

Symptom: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './utils' is not defined by "exports" in .../package.json

Root cause: The consuming code imports a path that is not declared in the exports map. Once exports is present, Node.js blocks access to any path not explicitly listed — including paths that previously worked via direct file access.

Fix: Add the subpath to your exports map, or expose a glob entry: "./utils/*": "./dist/utils/*.js".

Singleton Bifurcation

HAZARD PREVENTION

Symptom: A class instance check fails (instanceof returns false), a registry is empty after population, or event listeners registered in one part of the app are never called.

Root cause: The package was loaded by both require() and import in the same process, creating two isolated instances of the module. Each copy maintains its own state.

Fix: Enforce a single entry point by making the CJS wrapper delegate to the ESM build via a wrapper pattern, or ship ESM-only and require Node.js ≥ 12.

ERR_REQUIRE_ESM

HAZARD PREVENTION

Symptom: Error [ERR_REQUIRE_ESM]: require() of ES Module .../node_modules/my-lib/index.mjs not supported.

Root cause: A CJS consumer tried to require() a package that declares "type": "module" or uses .mjs files without providing a "require" condition in its exports map.

Fix: Add a "require": "./dist/index.cjs" condition to the exports entry, or instruct consumers to upgrade to a Node.js version that supports require() of ESM (Node.js 22+, behind an experimental flag). See fixing require errors in pure ESM packages.

TypeScript Type Drift

HAZARD PREVENTION

Symptom: Type 'X' is not assignable to type 'X' even though the types look identical. Two packages that import the same library disagree on its types.

Root cause: The types condition in your exports map is missing or points to a declaration file generated for a different format. TypeScript resolves types separately from runtime values; if the two disagree, the type system sees two incompatible versions of the same type.

Fix: Place "types" first in every exports condition block and ensure .d.ts files are co-generated with their corresponding .mjs/.cjs artifacts by the same build step.

Condition Ordering Mistake

HAZARD PREVENTION

Symptom: TypeScript resolves the wrong declaration file, or bundlers pick the CJS artifact instead of the ESM one in a browser build.

Root cause: Conditions inside an exports entry are evaluated top-to-bottom; the first match wins. Placing "require" before "import" in an environment that matches both will route ESM consumers to the CJS build.

Fix: Always order conditions as typesimportrequiredefault. Full configuration details are in mastering the exports field.

Decision Guide: Which Package Format Should You Ship?

The right publishing strategy depends on who your consumers are, which tools they use, and how much backward compatibility you need to maintain.

Package Format Decision Tree A flowchart that guides library authors from the question of whether they have CJS consumers through intermediate questions about tree-shaking needs and Node.js version requirements to the final recommendation of ESM-only, CJS-only, or dual-format publishing. CJS consumers use your lib? Yes No ESM consumers need tree-shaking? Must support Node.js < 12? Yes No Yes No Dual Format ESM + CJS via exports CJS Only main + basic exports Dual Format ESM + CJS via exports ESM Only type:module + exports Both "Dual Format" paths use identical exports field structure See: Mastering the package.json exports field

Practical defaults for 2025 and beyond:

  • New libraries with no legacy consumers — ship ESM-only. Set "type": "module" and use a single "exports" entry with "types" and "import" conditions.
  • Established libraries with mixed consumer bases — ship dual format. Use a build tool (tsup, rollup, or esbuild) to emit both .mjs and .cjs outputs, then route them via conditional exports.
  • Internal tooling or CLI packages — CJS-only is the path of least resistance if no downstream bundler is involved.

For environment-specific routing (e.g. browser vs. Node.js builds), configure the exports field with nested "browser" and "node" conditions alongside the format conditions.

Configuring the exports Field

The exports field in package.json is the authoritative routing table for your package’s public API. When present, Node.js ignores main, module, and all paths not explicitly declared in the map.

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types":   "./dist/index.d.ts",
      "import":  "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.cjs"
    },
    "./utils": {
      "types":   "./dist/utils/index.d.ts",
      "import":  "./dist/utils/index.mjs",
      "require": "./dist/utils/index.cjs"
    }
  }
}

Condition order matters: Node.js and TypeScript evaluate conditions top-to-bottom and stop at the first match. "types" must come first so TypeScript resolvers find declarations before they reach runtime entries. "default" must be last as a catch-all for non-conforming resolvers.

Detailed configuration patterns — including glob subpath exports, conditionNames in Webpack/Vite, and the "browser" condition — are covered in mastering the exports field.

Cross-Environment Resolution Strategies

Module resolution behavior differs substantially between Node.js, modern browsers, and bundler toolchains.

Node.js natively supports the node: protocol (import fs from 'node:fs'), which bypasses node_modules lookups entirely. The "node" condition in exports maps targets Node.js specifically. Node.js 22+ adds experimental support for require() of ESM, reducing the friction of dual-format distribution.

Browsers have no concept of node_modules. They fetch modules over HTTP using full URLs or module map entries. The "browser" condition in an exports map routes browser-targeted bundlers to platform-safe builds that avoid Node.js built-ins.

Bundlers (Webpack, Vite, Rollup, esbuild) read their own conditionNames configuration to decide which conditions apply. Vite enables "browser" by default in client builds; Webpack requires explicit conditionNames in resolve.conditionNames. Mismatched condition sets are a frequent source of build-time surprises.

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "browser": {
        "import":  "./dist/browser.mjs",
        "require": "./dist/browser.cjs"
      },
      "node": {
        "import":  "./dist/node.mjs",
        "require": "./dist/node.cjs"
      },
      "default": "./dist/node.cjs"
    }
  }
}

The full breakdown of resolution differences across runtimes — including how to configure Node.js for native ESM support — is in browser vs Node.js module resolution.

TypeScript Integration and Declaration Files

TypeScript 5+ added two moduleResolution modes that align compiler behavior with modern conditional exports: "node16"/"nodenext" and "bundler". Earlier modes ("node", "classic") ignore the exports field entirely.

The "bundler" mode is appropriate for projects consumed by Vite, esbuild, or other bundlers that handle bare specifiers natively. "node16"/"nodenext" mirrors Node.js’s own resolution algorithm exactly, including the requirement for explicit file extensions on relative imports.

// tsconfig.json for a library targeting Node.js ≥ 16
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "declaration": true,
    "declarationMap": true,
    "declarationDir": "./dist/types",
    "outDir": "./dist"
  }
}

A mismatch between the moduleResolution used to compile the library and the one used to consume it causes silent type errors: the type checker may resolve a different .d.ts file than the runtime loads. Ensure your build pipeline generates declaration files that are co-located with — and reference — their corresponding .mjs and .cjs artifacts.

For complex declaration configuration including path mapping and tsconfig path aliases, see path mapping and module resolution strategies.

Publishing Workflows and Provenance Security

Modern publishing workflows automate dual-format compilation, artifact validation, and cryptographic verification before the tarball reaches the registry.

A minimal CI pipeline for a dual-format library:

# 1. Build both formats in parallel
npm run build           # emits dist/index.mjs, dist/index.cjs, dist/index.d.ts

# 2. Validate the package before publishing
npx publint             # checks exports map, condition ordering, file existence
npx attw --pack         # checks TypeScript type resolution against published shape

# 3. Preview what will be published
npm pack --dry-run

# 4. Publish with provenance (requires id-token: write in GitHub Actions)
npm publish --provenance --access public

The --provenance flag generates a Sigstore attestation that links the published tarball to the exact CI workflow run, source commit, and build environment. This provenance attestation is stored in the npm registry’s transparency log and can be verified by consumers with npm audit signatures. The full pipeline — from a GitHub Actions release workflow through OIDC trusted publishing to provenance and Sigstore attestation — is covered in CI/CD, publishing & npm provenance.

HAZARD PREVENTION

Symptom: npm ERR! code ENEEDAUTH or provenance attestation fails silently.

Root cause: npm publish --provenance requires the id-token: write GitHub Actions permission and an npm access token with automation or publish scope. Missing either permission causes provenance generation to fail, sometimes without a clear error.

Fix: Add permissions: { id-token: write } to your GitHub Actions workflow job, and ensure your NPM_TOKEN secret uses a Granular Access Token scoped to the specific package.

Topic Index

Understanding ESM vs CJS Module Formats

A deep dive into synchronous CJS evaluation versus static ESM parsing, including how Node.js determines module format from file extensions and "type" field values. Read guide →

Includes: How to configure Node.js for native ESM support


Explains exactly why two module caches exist, which runtime scenarios trigger bifurcation, and the wrapper-pattern strategies that eliminate it without sacrificing tree-shaking. Read guide →

Includes: Fixing require() errors in pure ESM packages


Mastering the package.json Exports Field

Complete reference for the exports map: condition ordering, subpath patterns, glob entries, and the "browser"/"node" nested structure. Includes tooling validation steps with publint and attw. Read guide →

Includes: Conditional exports for development vs production


Browser vs Node.js Module Resolution

Compares resolution algorithms across Node.js, Vite, Webpack, Rollup, and the browser’s native module loader. Covers conditionNames, the browser condition, and how polyfill strategies differ per tool. Read guide →

Includes: Resolving type:module conflicts in legacy projects


package.json Fields for Distribution

How main, module, exports, types, files, and sideEffects each steer resolution and packaging, their precedence across Node.js, bundlers, and TypeScript, and how to control the published tarball. Read guide →

Includes: main, module, and exports precedence

Frequently Asked Questions

Can I safely publish both ESM and CJS formats in a single package?

Yes. Use the exports field with explicit "import" and "require" conditions to route consumers to the correct artifact. The key requirement is that both builds are generated from the same source in the same CI step, so their APIs stay in sync.

How does TypeScript 5+ handle dual module resolution?

TypeScript 5+ added "moduleResolution": "bundler" and improved "node16"/"nodenext" support. Both modes respect the exports field and route the "types" condition to declaration files. Earlier "node" mode ignores exports entirely and falls back to main.

Is sigstore provenance required for npm publishing?

Not required, but increasingly expected by enterprise consumers and supply-chain security audits. npm publish --provenance generates a Sigstore-backed attestation that is verifiable without trusting the publisher — it links the tarball to a specific GitHub Actions run and commit hash.

Why is the exports field preferred over main and module?

The exports field enforces strict path resolution (no implicit access to internal files), supports conditional routing per environment, and is the only mechanism Node.js uses for "require" vs "import" disambiguation. The "module" field is a bundler-only convention, not a Node.js standard, and is not respected by the Node.js runtime at all.

What happens if I omit the "default" condition?

Tools that do not recognize any of your declared conditions (older bundlers, custom resolvers) will throw ERR_PACKAGE_PATH_NOT_EXPORTED or silently fail to resolve the package. A "default" fallback pointing to your CJS build ensures backward compatibility.


← Back to home