ESM/CJS Interop: Solving Problems Between Module Systems
AI generated
JS
() =>
JavaScript · Node.js · Module Systems
ESM/CJS interop: solving problems between module systems
from default exports to the dual package hazard

ESM and CommonJS are two fundamentally different module systems that must coexist in the same Node runtime, and that is exactly where ESM/CJS interop problems come from. Anyone who does not understand the difference between require() and import will eventually hit ERR_REQUIRE_ESM, lost named exports, or a broken instanceof check caused by a dependency loaded twice.

18 min read exports field · require() · dual package · cjs-module-lexer Node.js 18+ · Node.js 22+

1. Why ESM and CJS are not simply compatible

CommonJS was designed before ES modules were standardized and resolves dependencies synchronously at runtime via require(), with every module returning a single module.exports object. ES modules, on the other hand, build a static module graph before execution even starts, and import/export are wired syntactically, they cannot be called conditionally at runtime. ESM/CJS interop problems arise exactly at this boundary: two systems with different loading philosophies have to cooperate inside the same process.

Node.js implemented both systems in parallel, but the bridge between them is not a fully transparent translation. A CJS module loaded via import from ESM shows up as a single default export object, while an ESM module that CJS wants to load via require() historically failed completely, because require() is synchronous and ESM evaluation is asynchronous. The ESM/CJS interop rules Node defines for this are pragmatic but surprising in several places for anyone hitting them for the first time.

Anyone writing libraries meant to be consumed by both CJS and ESM consumers needs to actively understand these ESM/CJS interop rules, not just get them right by accident. Misconfigured packages produce errors that only surface at the consumer's end, often with cryptic messages such as ERR_REQUIRE_ESM or missing named exports that looked perfectly visible in the editor.

2. module.exports vs. export default: what happens on import

In CommonJS, module.exports is a single mutable object that can have arbitrary properties assigned, while exports.foo = bar is merely a shorthand pointing at the same object. ES modules, by contrast, have clearly separated named exports and exactly one optional default export per module, both captured at static analysis time, not assembled at runtime. This structural difference is the core of every ESM/CJS interop problem.

When ESM imports a CJS module, Node treats the entire module.exports object as the default export. Named exports for CJS modules get additionally synthesized through static analysis, more on that in the section on cjs-module-lexer. The most common mistake here: developers expect export default in ESM to behave exactly like module.exports = in CJS, but these are two different concepts that only look similar on the surface.


// legacy-utils.cjs — CommonJS module
function formatPrice(cents) {
  return (cents / 100).toFixed(2);
}
function parsePrice(str) {
  return Math.round(parseFloat(str) * 100);
}

module.exports = { formatPrice, parsePrice };
// Equivalent shorthand form:
// exports.formatPrice = formatPrice;
// exports.parsePrice = parsePrice;

// consumer.mjs — ES module importing the CJS file above
import utils from "./legacy-utils.cjs";
// The whole module.exports object arrives as the default export
console.log(utils.formatPrice(1999)); // "19.99"

// Node can ALSO synthesize named exports via static analysis:
import { formatPrice, parsePrice } from "./legacy-utils.cjs";
console.log(formatPrice(500)); // "5.00"

The named import variant only works because Node statically scans the CJS source for recognizable assignment patterns. More complex assignments, say module.exports = computeExports() with dynamic computation, cannot be statically analyzed, so the default import path remains the only reliable option for ESM/CJS interop in that case.

3. package.json: type, the exports field and dual packages

The "type" field in package.json determines how Node interprets .js files by default: "type": "module" treats .js as ESM, "type": "commonjs" or the absence of the field treats .js as CJS. Regardless of this, .mjs files are always interpreted as ESM and .cjs files always as CJS, the most reliable way to mark a file's module system explicitly, independent of the type field.

The exports field is the central building block for clean ESM/CJS interop in packages meant to be consumed from both sides. It allows specifying different entry points for require and import, so called conditional exports. That way a package can ship both a CJS and an ESM variant without consumers having to figure out on their own which file matches their module system.


{
  "name": "@mironsoft/price-utils",
  "version": "2.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./package.json": "./package.json"
  }
}

The "main" key remains as a fallback for very old tooling that does not know the exports field yet, but is ignored by modern Node and modern bundlers as soon as exports is present. This leads to a classic dual package hazard: a package ships two separate files for the same module, one for CJS and one for ESM. If the same library gets accidentally loaded both via require and via import in the same process, two separate module instances with their own state come into existence, which silently breaks instanceof checks and singleton patterns.

4. require() from ESM: the new synchronous behavior in Node 22

For a long time it was impossible in Node to load an ESM module via require() from CJS, because ESM evaluation is fundamentally asynchronous while require() must return synchronously. Since Node 22 behind an experimental flag, and enabled by default from Node 23, require() can actually load synchronous ESM, as long as the target module contains no top level await expressions that would force a genuine asynchronous pause in the module graph.

This feature solves a very practical ESM/CJS interop problem: legacy codebases built entirely on CJS but wanting to pull in a modern, ESM only library used to either need a complete migration to ESM or a dynamic import() bridge with an asynchronous API. With synchronous require() of ESM, that migration pressure disappears for many projects, at least for dependencies without top level await.


// legacy-app.cjs — a CommonJS codebase
// Node 22+ (with --experimental-require-module) or Node 23+ by default:
const { chalk } = require("chalk"); // chalk v5 is ESM-only

console.log(chalk.green("This works synchronously now"));

// Still fails if the target module has top-level await:
// const mod = require("./has-top-level-await.mjs");
// -> ERR_REQUIRE_ASYNC_MODULE

// The traditional async bridge, still valid everywhere:
async function loadEsmDependency() {
  const { default: esmOnlyLib } = await import("esm-only-lib");
  return esmOnlyLib;
}

Important for library authors: this behavior depends on the consumer's Node version, not your own. Anyone writing a library for a broad audience cannot assume every user already runs Node 22 or 23, and should still offer a genuine CJS variant via the exports field rather than relying exclusively on this newer ESM/CJS interop feature.

5. import from CJS: default interop and the esModule flag

Going the other direction, ESM importing CJS, there is another subtlety that frequently causes confusion: Babel and TypeScript mark ESM modules they transpile with a property __esModule: true on the exported object. Tools that know this convention, such as bundlers with interop helper functions, behave differently from the native Node runtime, which ignores that flag and always treats the entire module.exports object as the default.

This leads to a well known class of bugs: a module compiled with Babel that originally used export default MyClass ends up, after compilation, as { __esModule: true, default: MyClass }. If that compiled result is loaded natively via require() from real CJS code, you have to explicitly access .default, while a bundler with an interop helper unwraps it automatically. This discrepancy between bundler behavior and the native Node runtime is one of the most common sources of ESM/CJS interop confusion in mixed toolchains.


// Compiled output from a Babel/TS build with __esModule marker
// dist/logger.js (CommonJS output, but originally written as ESM)
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = class Logger {
  log(msg) { console.log(`[LOG] ${msg}`); }
};

// Native Node require() sees the raw module.exports object:
const loggerModule = require("./dist/logger.js");
const Logger = loggerModule.default; // must access .default explicitly
new Logger().log("native require needs .default");

// A bundler with interop helper (e.g. Webpack's __esModule check)
// would let you write:
// import Logger from "./dist/logger.js"; // works transparently

6. Conditional exports for real dual publishing

Conditional exports in the exports field are the most robust solution for ESM/CJS interop, because they delegate the decision of which file gets loaded to the Node runtime itself, instead of leaving it to the consumer or a bundler. The "import" and "require" conditions apply based on exactly which syntax loaded the package, independent of the loading package's own "type" field.

For library authors this means the build process needs to produce two separate outputs, typically via two build targets in a tool like tsup or unbuild, one .mjs file with real ES module exports and one .cjs file with classic module.exports. Both files need to deliver functionally identical behavior, otherwise CJS and ESM consumers of the same package end up with different bugs, which is extremely hard to debug in practice because the problem only shows up for a subset of users.

7. Named exports from CJS: static analysis via cjs-module-lexer

As already hinted in the section on default exports, Node automatically synthesizes named exports when ESM imports a CJS module, as long as they are statically detectable. Responsible for that is the internal cjs-module-lexer, a specialized, extremely fast parser that scans CJS source for patterns like exports.foo = ..., module.exports.foo = ..., or Object.defineProperty(exports, "foo", ...), without actually executing the code.

The limits of this static analysis are practically relevant for ESM/CJS interop: dynamically computed export names, conditional assignments inside loops, or re-exports via spread operators onto module.exports are often not reliably detected by the lexer. In such cases, the default import remains the only guaranteed working path, even if the more intuitive named import syntax would look cleaner.


// analyzable.cjs — cjs-module-lexer CAN detect these patterns
exports.add = (a, b) => a + b;
module.exports.subtract = (a, b) => a - b;
Object.defineProperty(exports, "multiply", { value: (a, b) => a * b });

// NOT reliably analyzable — falls back to default-only import
const ops = { divide: (a, b) => a / b };
module.exports = { ...ops, extra: computeDynamically() };

// Safe consumption regardless of analyzability:
import cjsModule from "./analyzable.cjs";
const { add, subtract, multiply } = cjsModule; // always works

8. Common errors: ERR_REQUIRE_ESM and the dual package hazard

The error ERR_REQUIRE_ESM occurs when code tries to load a pure ESM module via classic require(), without the runtime supporting or enabling the newer synchronous ESM loading. The typical cause: a dependency switched from CJS to ESM only in a major update, a very common pattern across the ecosystem over the last few years, and your own code still uses require() instead of import(). The fix is either migrating your own project to ESM, using the newer require() ESM capability available from Node 22/23, or staying on an older, still CJS compatible package version.

The dual package hazard is subtler and harder to diagnose: if a package gets loaded both via require and via import in the same process, say because a dependency internally uses require while the application uses import, two completely separate module instances come into existence. For libraries with internal state, such as caches, registries or singleton patterns, this causes bugs where data appears to vanish because it landed in the other instance. This ESM/CJS interop problem can only be avoided through careful package design, in particular by avoiding module local mutable state in libraries that get dual published.

9. ESM and CJS side by side

The following overview summarizes the most important behavioral differences that most commonly cause ESM/CJS interop problems in practice.

Aspect CommonJS ES Modules Interop consequence
Loading behavior Synchronous, at runtime Static graph, partly async require() of ESM was historically impossible
Export structure One mutable object Named + one default export CJS import becomes a default object in ESM
this in module scope module.exports undefined Some CJS patterns break under ESM
__dirname/__filename Available Not available import.meta.url needed as an ESM replacement
Circular dependencies Partially filled objects Live bindings, more consistent ESM resolves many classic CJS circular bugs

This table shows that most ESM/CJS interop problems do not come from bugs in Node itself, but from fundamentally different design decisions that both systems considered sensible at the time they were created. Knowing these differences usually lets you avoid interop bugs while writing the code, rather than discovering them at the consumer's end.

Mironsoft

Node.js modernization and package architecture

ERR_REQUIRE_ESM in your CI before the customer notices?

We audit your dependencies for ESM/CJS fault lines, set up dual package builds with conditional exports, and guide the gradual migration of your codebase to ES modules.

Dependency audit

Detecting ESM only dependencies and potential dual package hazards

Package setup

exports field, conditional exports and dual builds for your own npm packages

Migration

Gradual transition from CommonJS to ES modules without downtime

10. Summary

ESM/CJS interop exists because two fundamentally different module systems must coexist in the same Node process: CommonJS with synchronous require() and a single mutable export object, ES modules with a static module graph and clearly separated named and default exports. The exports field in package.json with conditional exports is the most reliable way to ship a package correctly for both consumers, while cjs-module-lexer can only synthesize named exports from CJS for statically detectable patterns.

The most common sources of errors are ERR_REQUIRE_ESM for outdated import patterns and the dual package hazard for libraries with internal state. Since Node 22/23, synchronous require() of ESM without top level await adds extra flexibility, but it does not remove the need to consider ESM/CJS interop from the very start of package design, rather than discovering it at the first bug report from a consumer.

ESM/CJS interop — the essentials at a glance

Import of CJS from ESM

The entire module.exports object becomes the default export, named exports only for statically detectable patterns.

exports field

Conditional exports with import/require decide robustly which file loads, independent of the type field.

require() of ESM

Synchronously possible since Node 22/23, but not with top level await, and dependent on the consumer's Node version.

Dual package hazard

Two module instances from mixed require/import of the same package break state dependent libraries.

11. FAQ: ESM/CJS interop

1What does ESM/CJS interop mean?
The rules under which ES modules and CommonJS can import each other despite fundamentally different loading and export models.
2ESM imports CJS, what happens?
module.exports becomes the default export. Named exports are additionally synthesized through static analysis when detectable.
3Can require() load ESM?
Yes since Node 22/23, except with top level await. For broad compatibility the async import() bridge remains safer.
4What is the dual package hazard?
Mixed require/import of the same package creates two module instances with separate state, breaking singleton patterns.
5What is the exports field for?
Defines conditional exports for import/require so a package correctly serves both consumers without manual selection.
6Why do named imports sometimes fail?
The cjs-module-lexer only detects static assignment patterns, dynamic exports fall through, then only the default import helps.
7What is ERR_REQUIRE_ESM?
Occurs when require() tries to load a pure ESM module without synchronous ESM loading. Usually a dependency that became ESM only.
8What does __esModule do?
Marks Babel/TS transpiled ESM modules. Native Node ignores it, bundlers with interop helpers use it to unwrap the default export.
9Pure ESM or dual package?
Dual package with conditional exports is safer for broad compatibility, pure ESM saves build complexity for modern audiences.
10How to avoid dual package bugs?
Avoid module local mutable state, pass state explicitly, and test whether require/import deliver the same instance.