The ESM/CJS Incompatibility
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.
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
| Aspect | CommonJS | ES Modules |
|---|---|---|
| Loading | Synchronous — blocks until loaded | Asynchronous — returns a Promise or uses static linking |
| Binding type | Value copy at require() time | Live read-only binding to the original |
| Analysis | Dynamic — require() is a function call, can be conditional | Static — import declarations are parsed before execution |
| Evaluation | Eager — module executes immediately on first require() | Deferred — construction, instantiation, then evaluation as separate phases |
| Strict mode | Sloppy mode by default | Strict 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();
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.
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
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');
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.
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');
| What developers do | What 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 |
- 1require() cannot load ESM (unless --experimental-require-module in Node.js 22+ for sync-only ESM)
- 2ESM can import CJS via default import (always works) or named imports (heuristic, may fail)
- 3Dynamic import() works in both directions — it's the universal escape hatch
- 4The dual module hazard creates two separate instances of a package when CJS and ESM entry points are both loaded
- 5Use import.meta.dirname (Node.js 21.2+) instead of __dirname in ES modules