Skip to content

The ESM/CJS Incompatibility

intermediate13 min read

Why Can't They Just Get Along?

You've seen this error. Maybe multiple times:

Error [ERR_REQUIRE_ESM]: require() of ES Module ./node_modules/some-package/index.mjs
is not supported.
Instead change the require of index.mjs to a dynamic import() which is available
in all CommonJS modules.

Or this one:

SyntaxError: Cannot use import statement outside a module

These errors are the surface symptoms of a deep incompatibility between CommonJS and ES Modules. It's not just a syntax difference — these are fundamentally different systems with different execution models, different binding semantics, and different assumptions about timing.

Understanding why they don't mix is the key to navigating the messy transition period the JavaScript ecosystem is still going through.

Mental Model

Think of CommonJS and ESM as two different phone networks that happen to serve the same city. CJS is like a landline system — synchronous, immediate, you pick up the phone and the connection is instant. ESM is like a cellular network — asynchronous setup (the tower needs to establish a connection first), but once connected, you're linked in real-time (live bindings). Making a call from one network to the other requires a special bridge, and some features don't translate across the bridge.

The Five Fundamental Differences

AspectCommonJSES Modules
LoadingSynchronous — blocks until loadedAsynchronous — returns a Promise or uses static linking
Binding typeValue copy at require() timeLive read-only binding to the original
AnalysisDynamic — require() is a function call, can be conditionalStatic — import declarations are parsed before execution
EvaluationEager — module executes immediately on first require()Deferred — construction, instantiation, then evaluation as separate phases
Strict modeSloppy mode by defaultStrict mode always, no opt-out

Each of these differences creates a concrete interop problem. Let's walk through them.

Problem 1: require() Can't Load ESM

This is the most common wall developers hit. CommonJS require() is synchronous — it must return the module's exports immediately. But ES modules might use top-level await, which means they can't complete synchronously. Even without top-level await, the ESM loading pipeline (construction → instantiation → evaluation) is designed to be asynchronous.

// app.cjs (CommonJS)
const pkg = require('some-esm-package');
// ERR_REQUIRE_ESM — require() cannot load ES modules

The fix is to use dynamic import(), which is asynchronous and works in CommonJS:

// app.cjs (CommonJS)
async function main() {
  const pkg = await import('some-esm-package');
  // Works — but you're now in async land
}
main();
Common Trap

As of Node.js 22, there's experimental support for require() of synchronous ES modules (modules that don't use top-level await) behind the --experimental-require-module flag. This is a pragmatic compromise to ease migration, but it only works for ESM files that happen to be synchronous. If the ESM module uses top-level await, it still fails. Don't rely on this in production yet.

Quiz
Why can't CommonJS require() load an ES module that uses top-level await?

Problem 2: import Can Load CJS (But With Quirks)

Going the other direction is easier — ESM can import CommonJS modules — but there are caveats:

// utils.cjs (CommonJS)
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};

// app.mjs (ESM)
import utils from './utils.cjs';      // Default import works
utils.add(2, 3);                       // OK

// BUT — named imports might not work
import { add } from './utils.cjs';     // May or may not work!

Why the uncertainty with named imports? Node.js tries to statically analyze the CJS module to detect named exports. For simple module.exports = { a, b } patterns, it can often figure it out. But for anything dynamic (computed property names, conditional exports, Object.assign), the static analysis fails and you're stuck with default-only imports.

// This CJS pattern is too dynamic for static analysis:
// dynamic.cjs
const features = {};
if (process.env.FEATURE_X) {
  features.x = () => 'x';
}
module.exports = features;

// app.mjs — named import won't work
import { x } from './dynamic.cjs'; // Won't work — Node can't statically detect 'x'
import dynamic from './dynamic.cjs'; // Works — entire module.exports as default
dynamic.x?.();                        // Access properties on the default import
The named import detection varies by Node.js version

Node.js uses a package called cjs-module-lexer to detect CJS named exports. Its accuracy has improved over time, but it's fundamentally heuristic — it can't detect every pattern. When in doubt, use default imports for CJS modules and destructure after.

Problem 3: Live Bindings vs Value Copies

This difference creates subtle bugs when migrating from CJS to ESM or when mixing the two:

// counter.mjs (ESM)
export let count = 0;
export function increment() { count++; }

// esm-consumer.mjs (ESM)
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1 — live binding, sees the change

// cjs-consumer.cjs (CommonJS) — using dynamic import
const counter = await import('./counter.mjs');
counter.increment();
console.log(counter.count); // 1 — namespace object reflects live bindings

When CJS imports ESM via dynamic import(), the returned namespace object does have live binding semantics (it's a module namespace object). But when ESM imports CJS, the values are snapshots — because CJS module.exports is just a plain object, not a module namespace with live bindings.

Problem 4: __dirname / __filename vs import.meta.url

CommonJS modules get __dirname and __filename as injected variables. ES modules don't have these — they use import.meta.url instead:

// CommonJS
const path = require('path');
const configPath = path.join(__dirname, 'config.json');

// ESM — the old way (Node.js < 21.2)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const configPath = join(__dirname, 'config.json');

// ESM — the new way (Node.js 21.2+)
import { join } from 'node:path';
const configPath = join(import.meta.dirname, 'config.json');
Quiz
What is the ESM equivalent of __dirname in Node.js 22+?

Problem 5: The this Binding

A subtle difference that can break code during migration:

// CommonJS — top-level 'this' is module.exports
console.log(this === module.exports); // true (at the top level)

// ESM — top-level 'this' is undefined
console.log(this); // undefined (strict mode)

Some older CommonJS code uses this at the top level to add exports. That code breaks silently in ESM.

The Dual Module Hazard

The scariest interop problem is the "dual module hazard." If a package ships both CJS and ESM versions, you can end up with two separate instances of the same module in memory:

// Package "state-manager" ships both CJS and ESM

// cjs-consumer.cjs
const { store } = require('state-manager');
// Loads the CJS version — creates instance A

// esm-consumer.mjs
import { store } from 'state-manager';
// Loads the ESM version — creates instance B

// store A !== store B
// They're separate objects with separate state!

This is devastating for packages that rely on a shared singleton (state managers, registries, loggers). Two separate instances mean state is split, and nothing works correctly.

Common Trap

The dual module hazard is why package authors need the exports field in package.json — it lets them designate a single entry point for both CJS and ESM consumers, or use a thin CJS wrapper that re-exports the ESM version to ensure a single instance. We cover this in the next topic on dual packages and the exports field.

Practical Workarounds

Consuming ESM from CJS

// Option 1: Dynamic import (always works)
async function loadESM() {
  const { default: chalk } = await import('chalk');
  console.log(chalk.red('Hello'));
}

// Option 2: In Node.js 22+ with --experimental-require-module
// Only works for ESM without top-level await
const chalk = require('chalk');

Consuming CJS from ESM

// Option 1: Default import (always works)
import lodash from 'lodash';
lodash.chunk([1, 2, 3, 4], 2);

// Option 2: Named imports (works for simple export patterns)
import { chunk, map } from 'lodash';

// Option 3: createRequire — bring require() into ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./legacy-cjs-module');
Quiz
What is the main risk of having both CJS and ESM entry points for a stateful package?
What developers doWhat they should do
Assuming `require()` can load any ES module
require() is synchronous and cannot handle ESM's asynchronous loading pipeline, especially modules with top-level await.
Use dynamic `import()` to load ES modules from CommonJS
Using `__dirname` in ES modules
__dirname is injected by the CommonJS module wrapper and doesn't exist in ESM. import.meta provides the equivalent.
Use `import.meta.dirname` (Node.js 21.2+) or `dirname(fileURLToPath(import.meta.url))`
Expecting named imports from CJS to always work in ESM
Node.js uses static analysis to detect CJS named exports, but it can't detect dynamic patterns. Default import always works.
Use default import and destructure when named imports fail
Key Rules
  1. 1require() cannot load ESM (unless --experimental-require-module in Node.js 22+ for sync-only ESM)
  2. 2ESM can import CJS via default import (always works) or named imports (heuristic, may fail)
  3. 3Dynamic import() works in both directions — it's the universal escape hatch
  4. 4The dual module hazard creates two separate instances of a package when CJS and ESM entry points are both loaded
  5. 5Use import.meta.dirname (Node.js 21.2+) instead of __dirname in ES modules