Node.js CommonJS/ESM Interop: Edges and Dual Packages
CommonJS and ES Modules interop is the set of rules Node uses when code written for one module system crosses into the other. The split usually appears in mixed packages, dual builds, and dependency graphs that contain both require() and import. On one side, ESM can import CommonJS and receives a namespace shaped around module.exports. On the other, CommonJS reaches ESM through dynamic import() or through require(esm) when the target ESM graph can finish synchronously.
The Interop Join
Most interop problems come from identity and timing. The same package can expose separate CJS and ESM entry points, creating two module instances. A named import from CJS depends on static detection and synthetic namespace behavior. Conditional exports decide which file each consumer sees, so they make the package scope explicit instead of accidental.
The two module systems coexist in the same runtime, but they were not designed together. CJS was there first: synchronous, dynamic, built around module.exports, and cached by file path. ESM came later with static analysis, live bindings, and a multi-phase loading pipeline that can become asynchronous. Making these two systems talk to each other required compromises, and those compromises are where most of the confusion lives.
The earlier subchapters covered each side on its own: CJS's require() chain and resolution algorithm, then ESM's parse-link-evaluate pipeline and static analysis. The question now is what happens when those systems meet. When ESM code imports a CJS module, or when CJS code requires an ESM module, which system's rules shape the value that comes back?
The answer depends first on direction. It also depends on the Node version, because the CJS-to-ESM direction changed substantially once require(esm) arrived.

Figure 4.1 — The interop join is directional. ESM importing CJS receives a facade around module.exports; CJS loading ESM receives an ESM namespace only when the target graph can finish synchronously.
The "type" field and file extensions
Subchapter 03 covered how Node determines module format before parsing. Those rules count constantly at the split, so keep the condensed version in view:
.mjsfiles are always ESM..cjsfiles are always CJS..jsfiles follow the nearest parentpackage.json's"type"field."type": "module"means ESM."type": "commonjs"or absent means CJS.
The extension rules are fixed. Current Node also has syntax detection for ambiguous .js input that lacks an explicit "type" field, but package authors should not build an interop contract on ambiguity. The "type" field remains the single most consequential line in package.json for this topic because it determines what .js means throughout the package.
Importing CJS from ESM
The easier direction is ESM consuming CJS. ESM can import CJS modules directly, and the reliable shape is simple: module.exports appears as the default export.
Static import
import config from './config.cjs';When the ESM loader reaches this import, Node still loads config.cjs through the CommonJS loader. After the CJS file finishes evaluating, the resulting module.exports value is exposed through an ESM namespace wrapper, and that value becomes the default export in the ESM context.
So if the CJS module assigns an object:
module.exports = { port: 3000, host: 'localhost' };the ESM import receives that object as config, so code reads config.port and config.host in the ordinary way.
That default mapping is the stable part of the bridge. Named exports are a stricter compatibility layer on top of it.
Named export extraction from CJS
When ESM imports a CJS module, Node also attempts to extract named exports from the CJS source text. That is why this sometimes works:
import { readConfig } from './config.cjs';when the CJS module exposes a matching static property:
// config.cjs
exports.readConfig = () => ({ port: 3000 });Node uses cjs-module-lexer to analyze the CJS source text and identify likely export names without running the module. When that analysis succeeds, the detected names become available as named exports alongside the default export. You get the whole module.exports value as default, plus a named export for each property the lexer found. Built-in modules such as node:fs also provide named exports, but built-ins have their own integration path and are not the best example for userland CJS source analysis.
Because the names come from source analysis, the exact CJS shape is important. This pattern is detected on Node v24:
const foo = 1;
const bar = 2;
module.exports = { foo, bar };With that source, all three of these imports work from ESM:
import whole from './lib.cjs'; // { foo: 1, bar: 2 }
import { foo, bar } from './lib.cjs'; // 1, 2
import whole2, { foo as f } from './lib.cjs'; // bothThe default import gives you the entire object. The named imports give you individual properties, and default and named imports can share one declaration.
Do not generalize that behavior to every object literal. In Node v24.15, module.exports = { foo: 1, bar: 2 } exposes only the default markers, and import { foo } from './lib.cjs' fails. If named imports from CJS are part of your public contract, direct assignments are the safest shape:
exports.foo = 42;
module.exports.bar = 'hello';The lexer can also detect common Object.defineProperty(exports, ...) patterns and some identifier-valued module.exports = { ... } assignments. It operates on source text, not runtime values, so it does not discover computed export names or arbitrary property expressions by executing the module.
That limitation is what breaks dynamic exports. If a CJS module builds its exports at runtime:
const methods = ['get', 'post', 'put', 'delete'];
methods.forEach(m => { exports[m] = createHandler(m); });cjs-module-lexer cannot detect those exports. The names are computed from an array at runtime. Static analysis sees a forEach call, not concrete export assignments, so modules like this expose only the default export to ESM.
The implication follows directly from that constraint: use named imports from CJS only when you maintain the static export shape or have already verified it. If Node cannot extract a name, load fails with an error saying the named export does not exist. The reliable fallback is to import the default and destructure from it:
import pkg from './dynamic-exports.cjs';
const { get, post, put } = pkg;That last line is ordinary JavaScript destructuring. It copies the current property values into local bindings; it does not create live ESM bindings. Named exports copied from CJS into an ESM namespace also do not receive live updates when new properties are later added to module.exports, so the default import remains the more reliable representation of a CJS module.
Dynamic import of CJS
The same shape appears when ESM uses dynamic import() for a CJS module:
const mod = await import('./config.cjs');
console.log(mod.default); // module.exports valueThe promise resolves to a module namespace object with a default property pointing to module.exports. In current Node versions, the namespace also exposes a 'module.exports' marker with the same value. If cjs-module-lexer successfully extracted named exports, they appear as additional properties alongside those default markers.
The namespace object always has a default property, even when the CJS module's module.exports is undefined. What changes is the value inside that property, because it always reflects what the CJS module assigned to module.exports.
Importing ESM from CJS
CJS consuming ESM is stricter because require() is synchronous while ESM evaluation can become asynchronous.
ERR_REQUIRE_ESM
Before require(esm), calling require() on an ESM module threw ERR_REQUIRE_ESM. A CJS module could still reach ESM, but only through dynamic import(), which returns a promise:
async function loadESM() {
const mod = await import('./lib.mjs');
return mod.default;
}That works mechanically, but it pushes async control flow into code that may have been written around synchronous startup. If a CJS application calls require('./config') and that config file becomes ESM, it is not a drop-in replacement. The startup path must now handle a promise. For library authors, that meant an ESM-only release could break CJS consumers that expected synchronous loading.
The ecosystem split across dual builds, CJS-only releases, and ESM-only releases that required CJS consumers to switch to dynamic import().
The require(esm) path
Node added this capability in v20.17 and v22.0 behind --experimental-require-module. It became unflagged in v20.19, v22.12, and v23.0. It stopped emitting an experimental warning by default in v20.19, v22.13, and v23.5. Node v24.15 marks the feature as no longer experimental. For packages that only need unflagged support, a practical engine range is ^20.19 || >=22.12; for no default warning on the v22 line, use >=22.13.
With this feature available, require() can load modules that Node identifies as ESM. The constraint is timing: the ESM module must be fully synchronous, with no top-level await anywhere in the module or its dependency graph.
// works in Node versions with require(esm) support
const { readFile } = require('./esm-utils.mjs');If the required ESM module uses top-level await, require() throws ERR_REQUIRE_ASYNC_MODULE. The reason is the old CJS contract: require() must return a value immediately. It has no place to await the ESM evaluation promise, so an ESM evaluate phase that cannot finish synchronously cannot be represented as a normal require() result.
The synchronous constraint is transitive. If a.mjs imports b.mjs, and b.mjs has top-level await, then require('./a.mjs') also throws ERR_REQUIRE_ASYNC_MODULE. The entire dependency graph reachable from the required module must be synchronous.
When require(esm) succeeds, the usual return value is the module namespace object. Named exports and the default export are all available as properties:
const utils = require('./utils.mjs');
console.log(utils.default); // default export
console.log(utils.helperFn); // named exportThat is a different shape from ordinary CJS. Requiring a CJS module returns module.exports directly. Requiring an ESM module returns the namespace object, where the default export is only one property among the named exports.
There is one compatibility path for preserving a CJS-shaped API. If the ESM module exports a binding with the string name 'module.exports', require(esm) returns that value instead of the namespace object:
// point.mjs
export default class Point {}
export { Point as 'module.exports' };const Point = require('./point.mjs');
console.log(Point); // [class Point]That customization can help during migrations, but it has a cost. Named exports are no longer available to CJS consumers through destructuring unless you attach them to the returned value yourself.
Dynamic import() from CJS
Dynamic import() remains the compatibility path from CJS because it is already async. It returns a promise that resolves to the module namespace:
(async () => {
const mod = await import('./lib.mjs');
console.log(mod.someFunction);
console.log(mod.default);
})();You can use import() from top-level CJS by wrapping the module body in an async IIFE, or you can call it inside async functions. The namespace object has named exports as properties and a default property for the default export, just like an ESM static import namespace.
This async path is the universal fallback across current Node.js releases. If CJS cannot use require() because of the Node version, top-level await in the target, version constraints, or a cross-format cycle, use import() and structure the caller around the promise.
Comparing the two directions
The asymmetry is easiest to see side by side:
CJS from ESM (ESM is the consumer):
import x from './lib.cjs'- default export ismodule.exportsimport { named } from './lib.cjs'- works ifcjs-module-lexerdetects the exportawait import('./lib.cjs')- returns{ default: module.exports, 'module.exports': module.exports, ...namedExports }in current Node- Works on Node releases with ESM support. No
require(esm)feature is involved.
ESM from CJS (CJS is the consumer):
require('./lib.mjs')- returns the module namespace object on supported Node versions when the ESM graph is synchronousrequire('./lib.mjs')can return a custom value if the ESM exports'module.exports'await import('./lib.mjs')- returns the module namespace object- The namespace object has
defaultplus all named exports as properties, and may include__esModule: truewhen returned fromrequire(esm)for a module with a default export require()throwsERR_REQUIRE_ASYNC_MODULEif any module in the graph uses top-level await
The difference is the returned shape. CJS imported into ESM exposes module.exports as the default export. ESM required from CJS gives you the whole namespace object, and the default export is just one property on that namespace. If you switch directions, the value you receive changes shape.
The entry point is important too. import() always enters the ESM loader. require() always enters the CommonJS loader first, even when it later delegates to the ESM machinery for require(esm).
The dual package hazard
Packages that ship both CJS and ESM entry points can run into a separate identity problem. The same package can be loaded twice: once through the CJS loader and once through the ESM loader. That means two module instances and two copies of any module-level state.
A concrete package layout makes the failure easier to see. Suppose my-lib ships:
dist/cjs/index.cjs(CJS entry)dist/esm/index.js(ESM entry)
Your application imports my-lib from ESM, so Node loads the ESM entry. A dependency elsewhere in node_modules does require('my-lib'), so Node loads the CJS entry. There are now two copies of my-lib in memory, which duplicates module-level state such as connection pools, config caches, singleton instances, and class identities.

Figure 4.2 — The dual package hazard appears when import and require() resolve to different build outputs. Each output can evaluate separately, so module-level state and identity are no longer shared.
The consequences go beyond wasted memory. instanceof checks break across the split. An object created by the ESM instance of my-lib is not an instanceof a class from the CJS instance, even though those classes came from the "same" package. Any type-checking logic that relies on identity can then fail silently.
Why it happens
CJS and ESM maintain separate module caches. The CJS cache lives in Module._cache, keyed by absolute file path. The ESM cache is managed by the ESM loader, keyed by URL. When the CJS and ESM entries are different files (dist/cjs/index.cjs vs dist/esm/index.js), they get different cache keys. Two files mean two cache entries and two module evaluations.
Even if the code in those files is functionally identical, the runtime treats them as separate modules. There is no automatic deduplication across loaders.
Mitigation strategies
Strategy 1: Stateless packages. If your package has no module-level state and exports only pure functions, constants, and stateless classes, the dual package hazard is a non-issue. Two copies of the same pure functions do not conflict. This is the simplest approach and the recommended one for libraries where it is feasible.
Strategy 2: The wrapper approach. Ship ESM as the canonical implementation, then make the CJS entry a thin wrapper that re-exports from the ESM version:
// dist/cjs/index.cjs
module.exports = require('../esm/index.js');On Node versions with require(esm) support, this works as long as the ESM graph is synchronous. Both entry points ultimately execute the same ESM module. CJS receives the ESM namespace by default, or a custom value if the ESM uses the special 'module.exports' export. Either way, there is one canonical implementation file instead of separate CJS and ESM implementations.
Before require(esm) was available, a CJS wrapper could only reach ESM with dynamic import():
// dist/cjs/index.cjs (legacy approach)
module.exports = import('../esm/index.js');That returns a promise from require(), which breaks CJS consumers expecting synchronous access. Use that shape only when consumers knowingly accept a promise-valued API.
Strategy 3: Shared state via a separate module. Factor all state into one internal module, either CJS or ESM, and have both entries import it. The state module gets loaded once and cached. Both wrappers then reference the same cached state.
Strategy 3 is more complex than the wrapper approach but works when you cannot make one entry simply re-export the other.
Strategy 4: ESM-only. Publish only ESM when your supported Node range and consumer base can tolerate the migration. As of May 14, 2026, Node 22 and Node 24 are the supported LTS lines, and both support require(esm) in their current releases. ESM consumers import normally, and CJS consumers either use dynamic import() or run a Node version new enough for require(esm). The tradeoff is clear: you simplify your build, but CJS consumers need to adapt.
How to detect the dual package hazard in practice: if a module-level Map, Set, or singleton is supposed to be shared globally and consumers report that it is empty or duplicated, you are probably seeing this hazard. Another telltale is an instanceof check that should pass but returns false. Log the file path of the module where the class or state lives from both the CJS and ESM sides. If the paths differ, you have two instances.
Conditional exports in package.json
The "exports" field in package.json is the mechanism that makes dual packages explicit. It maps entry points to different files based on how the package is being loaded.
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
}
}When code writes import 'my-lib', Node matches the "import" condition and resolves to ./dist/esm/index.js. When code writes require('my-lib'), Node matches the "require" condition and resolves to ./dist/cjs/index.cjs.

Figure 4.3 — Conditional exports make the package scope explicit. The condition map decides which entry a consumer receives, and a synchronous ESM branch can let both loaders share one implementation when its namespace shape is acceptable.
Order is significant because Node evaluates conditions top-to-bottom and takes the first match. The "import" and "require" conditions are the two main ones for CJS/ESM interop, but other conditions often appear in real packages:
"node"- matches when running in Node.js (vs. browser bundlers)"module-sync"- matches bothimportandrequire()when the target is an ESM graph that can be evaluated synchronously"default"- fallback if nothing else matches"types"- used by TypeScript and other typing systems for type resolution- Custom conditions - can be set with the
--conditionsflag
A more complete exports map might look like:
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"module-sync": "./dist/esm/index.js",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
}
}The "types" condition should come first because typing tools expect to match it before runtime conditions. Node ignores unknown conditions by default; typing tools and user conditions are what make "types" meaningful. "default" should come last as the fallback. Use "module-sync" only when the ESM target and its dependency graph do not use top-level await; otherwise a CJS require() of that branch throws ERR_REQUIRE_ASYNC_MODULE.
When one synchronous ESM implementation can serve both import and require(), "module-sync" can reduce the dual package hazard by routing both consumers to the same file. That only works when the ESM graph stays synchronous and the returned namespace shape is acceptable for CJS consumers.
Subpath exports
You can expose multiple entry points:
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
},
"./utils": {
"import": "./dist/esm/utils.js",
"require": "./dist/cjs/utils.cjs"
}
}Consumers can now write import { helper } from 'my-lib/utils' or const { helper } = require('my-lib/utils'), with each form resolving to the appropriate format.
The "exports" field also restricts the package's public API for package-specifier imports. If a path is not listed in "exports", consumers cannot import it through the package name. Trying import 'my-lib/dist/internal/secret.js' throws ERR_PACKAGE_PATH_NOT_EXPORTED. Before the "exports" field existed, any file in a package was accessible by package-relative path. The "exports" field introduced package encapsulation: consumers only access what the package explicitly exposes. This is not filesystem security; a direct absolute path can still load a file.
The "main" and "module" fallbacks
If "exports" is not present, Node falls back to "main" for the package's main entry point:
{
"main": "./dist/cjs/index.js"
}The "module" field, such as "module": "./dist/esm/index.js", is recognized by bundlers like webpack and Rollup, but Node itself ignores it. Node uses "exports" or "main", not "module". If you are setting up a dual package, use "exports" with conditions. The "module" field can live alongside it for bundler compatibility, but it is not part of Node's resolution.
Setting up a dual build
The common dual-package approach is to build the same source code to both formats. A typical project structure looks like this:
src/
index.js (source, written in ESM)
dist/
esm/index.js (ESM build output)
cjs/index.cjs (CJS build output)
package.jsonBuild tools like tsup, unbuild, and esbuild handle this transformation. They take ESM source and produce both an ESM copy, possibly with minor transformations, and a CJS version where import becomes require() and export becomes module.exports.
A minimal tsup configuration:
export default {
entry: ['src/index.js'],
format: ['esm', 'cjs'],
outDir: 'dist',
};The package map then points each consumer type at the matching build:
{
"type": "module",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
}
}The .cjs extension is important again here. When package.json has "type": "module", all .js files in the package are ESM. The CJS build output needs the .cjs extension so Node treats it correctly. Build tools usually handle that naming automatically.
Some projects take the opposite approach: write CJS source and produce only CJS output, then use an ESM wrapper as the ESM entry:
// esm-wrapper.js
export { default } from './dist/index.cjs';
export * from './dist/index.cjs';The wrapper re-exports the detectable names from the CJS build. Named export extraction makes export * work for common CJS patterns, but dynamic CJS export patterns still need explicit handling through a default import. On Node v23 and newer, export * from a CJS module can also expose the 'module.exports' marker. Use explicit re-exports when that marker should not become part of the public ESM surface. This approach reduces build complexity, but ESM consumers run through the CJS loader, so copied named exports do not have ESM live-binding semantics.
Testing both entry points
A common dual-package mistake is testing one entry point and shipping the other untested. The CJS build might differ subtly from the ESM build: a missing export, a different default value, or a function that behaves differently because of how the build tool transformed async/await.
The simplest guard compares the exported names:
import assert from 'node:assert/strict';
import * as esmExports from '../dist/esm/index.js';
import cjsExports from '../dist/cjs/index.cjs';
const esmKeys = Object.keys(esmExports)
.filter((key) => key !== 'default' && key !== 'module.exports')
.sort();
assert.deepStrictEqual(esmKeys, Object.keys(cjsExports).sort());That test checks that both entries expose the same set of intended export names while ignoring namespace markers. It does not verify behavior, but it catches the common mistake where the CJS build drops or renames an export. For behavior parity, run the actual test suite against both entry points. Most test runners can be configured to run twice with different import paths.
Package.json for a real-world dual package
A complete package.json for a dual CJS/ESM package usually looks something like this:
{
"type": "module",
"main": "./dist/cjs/index.cjs",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"files": ["dist"]
}The "main" field provides a fallback for older tools that do not understand "exports". The "type": "module" field tells Node that .js files in this package are ESM. The "files" array controls what ends up in the npm tarball. The "exports" map handles conditional resolution for current Node.js releases and bundlers.
Common errors and how to debug them
Interop error codes usually narrow the failure to a specific interop rule. The message excerpts below are representative; match on the error code when code needs to branch, because exact text can change across Node releases.
ERR_REQUIRE_ESM
Error [ERR_REQUIRE_ESM]: require() of ES Module /path/to/module.mjsThis means code is calling require() on an ESM module in a Node version that does not support require(esm), or the feature has been disabled with --no-require-module. In Node v20.19+, v22.12+, and current v24 LTS releases, synchronous ESM normally loads through require() and returns the namespace object.
Check the Node version first with node --version. If the runtime is older than v20.19, or older than v22.12 on the v22 line, upgrade or switch to dynamic import(). Code that needs runtime detection can also check process.features.require_module.
ERR_REQUIRE_ESM was the error many CJS projects saw when dependencies published ESM-only major versions before require(esm) was available. The chalk v5 migration was a common example: chalk v4 was CJS, v5 went ESM-only, and CJS projects that upgraded needed to pin v4, switch to import(), convert to ESM, or later run on a Node version with require(esm) support.
ERR_REQUIRE_ASYNC_MODULE
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
graph with top-level awaitThe ESM module, or one of its imports, uses top-level await. require() cannot handle that graph. The choices are to switch to async import() or remove the top-level await from the ESM module.
To find the module with top-level await, start with the stack trace. If the await is in a transitive dependency, run with --experimental-print-required-tla during diagnosis; Node can evaluate far enough to print the top-level await locations before reporting the error.
ERR_REQUIRE_CYCLE_MODULE
Error [ERR_REQUIRE_CYCLE_MODULE]: Cannot require() ES Module in a cyclerequire(esm) does not support immediate cycles that cross from CJS into ESM. A typical failure is an ESM module importing a CJS module while that CJS module synchronously requires the original ESM module:
// a.mjs
import './b.cjs';// b.cjs
require('./a.mjs'); // throws ERR_REQUIRE_CYCLE_MODULEMove one side of the cycle behind a function call, use dynamic import(), or break the cycle by extracting shared code into a third module.
ERR_PACKAGE_PATH_NOT_EXPORTED
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './internal'
is not defined by "exports"This means the import targets a subpath that the package's "exports" field does not expose. The package author intentionally restricted access. Use a listed subpath, or, if you really need the internal file, bypass exports by importing the file directly by its full path relative to node_modules. That is fragile and breaks with any package restructuring.
Named export not found
SyntaxError: Named export 'someFunction' not foundThis happens when ESM tries to import a named export from a CJS module and cjs-module-lexer cannot detect that name. The CJS module probably uses dynamic export patterns. Fall back to a default import:
import pkg from 'the-package';
const { someFunction } = pkg;SyntaxError from mixing module syntaxes
SyntaxError: Cannot use import statement outside a moduleThe file is being treated as CJS because of its extension or the "type" field, but it contains static import statements. Rename the file to .mjs, or set "type": "module" in the nearest package.json. Exact syntax error text can vary by Node version, but this is the Node v24.15 message for a .cjs file with a static import.
The reverse error is rarer:
ReferenceError: module is not defined in ES module scopeThe reverse error means the file is ESM but uses CJS wrapper bindings such as module.exports. Rename it to .cjs, or remove the CJS export assignment and use export instead.
The default export confusion
Default export confusion does not always produce an error. Sometimes it just gives a value with an unexpected shape. A CJS module that does this:
module.exports = function greet() { return 'hello'; };maps cleanly to a default import from ESM:
import greet from './greet.cjs';
greet(); // works - greet is the functionBut this form asks for a named export:
import { greet } from './greet.cjs';That fails because there is no named export called greet. The entire function is the default export. Named exports only exist when the CJS module assigns individual properties to exports or module.exports and Node can detect them.
The mapping to remember is: module.exports = X maps to export default X. exports.foo = Y maps to export { Y as foo } (when cjs-module-lexer can detect it).
Debugging interop issues systematically
When an interop error is not obvious from the message alone, debug the split in a fixed order:
- Check the file's determined format. Run a small ESM evaluation to see how Node treats the file:
node --input-type=module --eval \
"import('./problematic-file.js').then((m) => console.log(m))"You can also start with the file extension and the nearest package.json's "type" field.
-
Check what exports are detected. For CJS modules,
await import('./module.cjs')in a.mjsscript and logObject.keys()of the result. If you only see default markers such asdefaultand'module.exports', named export extraction did not find useful names. -
Check the package's
"exports"field. Userequire.resolve()from CJS to inspect therequirebranch, andimport.meta.resolve()from ESM to inspect theimportbranch. Compare the resolved path with the"exports"map in the package'spackage.jsonto understand which condition matched. -
Check for dual loading. If you suspect the dual package hazard, add logging in the module's initialization code and watch if it runs twice.
Most interop issues fall into a small set of categories, and the error codes are usually specific enough to narrow the search quickly. A common root cause is a mismatch between what you think "type" is set to and what it actually is, especially in monorepos where different packages can have different "type" settings.
How the CJS/ESM bridge works internally
The split between CJS and ESM is implemented by translators that let one loader present a module-shaped view to the other.
The CJS translator path
When an ESM import statement targets a CJS file, Node cannot load that file by treating it as ordinary ESM. Many CJS files are syntactically valid JavaScript, but they depend on wrapper bindings such as require, exports, and module, plus the synchronous module.exports contract. Some CJS-only forms are syntax errors under ESM; others fail later as missing bindings.
Instead, Node creates a synthetic ESM facade around the CJS module. Internally, this work lives in lib/internal/modules/esm/translators.js. The relevant CJS translator gives the ESM loader a module-shaped view of a file that will still execute as CommonJS.
The order is important. Node first determines that the target file is CJS, then uses source-text analysis to find a best-effort list of named exports before the ESM namespace is linked. The namespace needs those export names early because ESM import bindings are established before evaluation. When the facade is evaluated, the CJS module runs through the CommonJS loader, populates module.exports, and Node copies the detected property values onto the ESM namespace.
The key point is that the ESM loader does not parse CJS source as ESM. It builds a facade from two pieces of information: export names discovered from CJS source text, and the module.exports value produced by CJS evaluation. That is why CJS loaded from ESM always has a default export pointing to module.exports, and current Node also includes a 'module.exports' marker for the same value. Detected named exports are copied values, not live bindings.
cjs-module-lexer
Named export extraction from CJS is handled by cjs-module-lexer, a dependency vendored into Node's source tree. It scans CJS source text and identifies likely export assignment patterns without evaluating the code.
The lexer recognizes these patterns:
- Direct property assignment:
exports.name = valueandmodule.exports.name = value - Object.defineProperty:
Object.defineProperty(exports, 'name', { ... })and the same pattern onmodule.exports - Some object literal assignments:
module.exports = { name, other: otherValue } - Re-export patterns:
module.exports = require('./other')and common transpiler re-export shapes
The object-literal case is best-effort. Identifier shorthand and simple identifier-valued properties can be detected; arbitrary expressions such as module.exports = { foo: 1 } are not a safe named-export contract. The lexer is intentionally narrower than a full JavaScript parser. It does not evaluate control flow, execute getters, or resolve arbitrary expressions. That restraint is part of the design: named export detection has to be cheap enough to run during module loading.
The same byte-scanning approach has inherent blind spots. Any export pattern that depends on computation or indirection at runtime is invisible to the lexer:
Object.assign(module.exports, someObject)- the lexer cannot evaluatesomeObjectexports[dynamicKey] = value- computed property names are opaque- Conditional exports inside
ifblocks - the lexer does not evaluate conditions, so it may detect names from a branch that never runs - Re-exports through intermediate variables:
const e = exports; e.foo = 42- the lexer tracksexportsandmodule.exportsdirectly, not aliases
When the lexer fails to detect exports, the fallback is clean: the CJS module gets the default markers for module.exports, but no useful named exports. A dynamic namespace import simply lacks those names. A static named import for a missing name throws during module loading.
You can check what Node exposes for a given CJS file by importing it dynamically and inspecting the namespace:
const ns = await import('./some-cjs-lib.cjs');
console.log(Object.keys(ns));That prints all named exports the lexer found, plus the default markers.
The synchronous ESM execution path
The require(esm) implementation takes a different path from dynamic import(). When the CommonJS loader detects that the target file is ESM and the feature is available, it delegates to the ESM machinery but requires the graph to finish synchronously.
The internal sequence is:
Module._load()callsModule._resolveFilename()as usual to find the file.- It detects that the target should be ESM based on extension, package
"type", or syntax detection rules. - The ESM loader parses, resolves, links, and evaluates the graph.
- If any module in the graph contains top-level await, the synchronous path throws
ERR_REQUIRE_ASYNC_MODULE. - If the graph tries to cross back into the same ESM through an immediate CJS/ESM cycle, the synchronous path throws
ERR_REQUIRE_CYCLE_MODULE. - If evaluation completes synchronously,
require()returns either the module namespace object or the value of the ESM module's special'module.exports'export.
require(esm) is constrained by the old require() contract: return a value now, not a promise later. Supporting ESM through require() therefore means allowing the ordinary ESM pipeline to complete synchronously when the graph does not use top-level await.
The resulting module is still an ESM module record. Loading the same ESM file by URL through import() and through require(esm) should observe the same ESM module instance. That does not solve the dual package hazard when a package maps "import" and "require" to different files. It only means the same ESM file is not turned into a separate CJS instance just because CJS initiated the load.
The important difference is file identity. One ESM file means one ESM module record. Two build outputs, such as dist/esm/index.js and dist/cjs/index.cjs, still mean two modules.
How the evaluation synchronicity check works
When Node evaluates an ESM graph, top-level await makes completion asynchronous. A synchronous require() caller cannot wait for that completion without changing the contract of require().
Node's synchronous wrapper checks whether evaluation can finish immediately. The check is recursive through the dependency graph: if module A imports module B, and B has top-level await, then A also cannot finish synchronously, which triggers the error when A is required.
The rule is simpler than the implementation: any top-level await anywhere below the required ESM entry makes require(esm) the wrong tool. Use dynamic import() for that graph.
The __esModule convention
Before Node had official CJS/ESM interop, bundlers like webpack and Babel invented their own conventions. When Babel compiled ESM to CJS, it added a property to the exports object:
Object.defineProperty(exports, '__esModule', { value: true });Bundlers checked for __esModule to decide whether to treat require() output as default-export-wrapped CJS or as a module namespace produced from transpiled ESM. If __esModule was truthy, the bundler knew the CJS code was originally ESM and treated exports.default as the default export rather than wrapping the entire exports object.
Node's CJS-from-ESM import path does not use __esModule to decide how to wrap a CJS default export. It has its own mechanisms: the translator system and cjs-module-lexer. But require(esm) may add __esModule: true to the returned namespace when the ESM has a default export, for compatibility with tools that compiled ESM to CJS and then consume real ESM through require(). That property is for tooling compatibility and should not become an application-level contract.
Practical patterns for library authors
If you are publishing a package that needs to work for both CJS and ESM consumers, the choice comes down to state, supported Node versions, and consumer expectations.
If your package is stateless (pure functions, no module-level singletons): ship dual builds via tsup or unbuild, use conditional exports, and do not worry about the dual package hazard.
If your package has state: use the wrapper approach when your supported Node range includes require(esm). Make ESM the canonical implementation. The CJS entry re-exports from ESM using require(), and the ESM entry can expose 'module.exports' if CJS consumers need the old direct-return shape. If you support Node versions before ^20.19 || >=22.12, keep a real CJS build, use dynamic import() for an explicitly async API, or accept the dual-instance tradeoff.
If you are writing an application rather than a library: pick one format and stick with it. Set "type": "module" for ESM. Do not bother with dual builds. Dependencies handle their own interop; application code can import what it needs and let Node sort out whether each dependency is CJS or ESM.
For the transition period: most real projects have dependencies spanning both module formats. Some dependencies only ship CJS. Some only ship ESM. A few ship both. Application code can be entirely ESM while consuming CJS dependencies through import; Node handles the translation. The split becomes visible when named export extraction fails and you need to fall back to default imports.
TypeScript adds another layer to the same handoff. If tsconfig.json has "module": "commonjs", TypeScript compiles ESM-style import/export to require/module.exports. If it has "module": "nodenext" or "module": "node16", TypeScript respects the file extension and "type" field to decide output format. Getting TypeScript and Node to agree on module format requires checking module, moduleResolution, file extensions, package "type", and package "exports" together, but the underlying principles are the same as the ones covered here.
Current Interop State
Modern supported LTS releases have fewer interop failure modes. require(esm) works without flags for synchronous ESM. Named export extraction handles many common CJS patterns. The "exports" field gives package authors a standard place to declare dual entry points. The wrapper approach can avoid the dual package hazard for stateful libraries when the supported Node range is new enough.
The remaining constraints are still real. Top-level await in ESM prevents require(). Immediate CJS/ESM cycles can throw ERR_REQUIRE_CYCLE_MODULE. Dynamic CJS export patterns defeat named export extraction. Conditional exports can still point import and require at different files, recreating the dual package hazard. CJS remains common on npm, while many new packages choose ESM-only distribution, so the split keeps showing up in real dependency graphs.
The stable operational rule is to check direction first, then timing, then identity. Direction tells you whether default means module.exports or an ESM namespace property. Timing tells you whether require() can finish synchronously. Identity tells you whether the package resolved to one file or two. Most interop bugs are one of those three constraints becoming visible at the module layer.