Node.js Stable Support for Requiring ESM Modules
For years, Node.js developers faced a painful divide: CommonJS (CJS) and ES Modules (ESM) coexisted with limited interoperability. CJS code could import() ESM modules asynchronously, but require() — the synchronous loading mechanism at the heart of CJS — could not load ESM at all. This forced codebase migrations to be all-or-nothing affairs.
That changed with Node.js 22 and 23, which stabilized the --experimental-require-module flag. As of Node 23, synchronous require() can load ES modules, provided the ESM graph does not use top-level await.
The Problem: CJS vs ESM Interoperability
The fundamental issue is that CommonJS loads modules synchronously (via require()), while ES Modules are asynchronously parsed and executed (top-level await, static import statements that return promises). ESM graphs can contain circular references and live bindings that CJS could not handle.
Before Node 22, the only way to load ESM from CJS was:
// CJS file
async function loadESM() {
const esmModule = await import('./esm-module.mjs');
return esmModule.default;
}
This forced CJS codebases to either rewrite everything to ESM or maintain awkward async wrapper layers.
The Solution: require(esm) in Node 22+
Node 22 introduced the --experimental-require-module flag, which enables require() to load ESM modules synchronously, as long as the ES module and its entire dependency graph have no top-level await.
Node 23 promoted this to a stable feature behind the --require-module flag (note: without “experimental”), with plans to enable it by default in Node 24.
// CJS file — works in Node 22+ with flag
const lodashEs = require('lodash-es'); // ESM package!
const result = lodashEs.merge({ a: 1 }, { b: 2 });
console.log(result); // { a: 1, b: 2 }
How It Works Under the Hood
When require() encounters an ESM file, the following resolution happens:
- Node identifies the module as ESM (via
package.json"type": "module"or.mjsextension) - It checks if the ESM graph contains top-level await — if yes, it throws a ERR_REQUIRE_ASYNC_MODULE error
- If no top-level await is present, it synchronously parses and evaluates the ESM graph
- The default export is returned directly; named exports are accessible via the returned object’s properties
// ESM module: esm-lib.mjs
export const greet = (name) => `Hello, ${name}!`;
export default class Logger {
log(msg) { console.log(msg); }
}
// CJS consumer
const lib = require('./esm-lib.mjs');
lib.greet('World'); // "Hello, World!"
new lib.default().log('hi'); // "hi"
Named exports require .default access for the default export, but named exports are directly available.
Supported ESM Features
| Feature | Supported in require(esm) | Notes |
|---|---|---|
| Named exports | Yes | Direct property access |
| Default export | Yes | Returned as .default or directly for single export |
| Live bindings | No | Require captures values, not live bindings |
| Cyclic dependencies | Partial | Works for simple cycles, may fail for complex graphs |
| Top-level await | No | Throws ERR_REQUIRE_ASYNC_MODULE |
| Dynamic import() | Yes | Works within ESM modules loaded via require |
Restrictions and Error Handling
The most important restriction is top-level await. Any ESM module that uses await at the top level cannot be loaded via require().
// top-level-await.mjs
const data = await fetch('https://api.example.com/data').then(r => r.json());
export default data;
// CJS consumer
const data = require('./top-level-await.mjs');
// ERR_REQUIRE_ASYNC_MODULE: require() cannot be used on an ES module
// with top-level await
To handle this, either:
- Refactor the ESM module to remove top-level await
- Use
await import()instead ofrequire() - Wrap in an async wrapper
// Workaround for top-level await ESM
async function load() {
return require('./top-level-await.mjs');
}
// Still fails — the issue is in the module itself, not how you call it
// Correct approach:
const data = await import('./top-level-await.mjs');
Migration Benefits for CJS Codebases
This feature dramatically simplifies the incremental migration from CJS to ESM:
Before (Node 20): You could not use ESM-only packages in CJS without await import() wrappers, making it impossible to use at the top level of a CJS module.
// Painful before — async wrapper required
const getExpress = () => import('express');
After (Node 23+): You can require() most ESM packages directly, enabling gradual migration:
// CJS file, Node 23+
const express = require('express'); // works if express ships ESM
const { nanoid } = require('nanoid'); // ESM-only package!
This means CJS codebases can adopt ESM-only dependencies without rewriting their module system. Libraries like nanoid, chalk (v5+), and p-limit are ESM-only but are now accessible from CJS.
Ecosystem Impact
The stabilization of require(esm) has significant ecosystem effects:
- Package authors can drop dual CJS/ESM builds and ship ESM-only, knowing CJS consumers can still
require()their packages - CJS codebases can gradually adopt ESM dependencies without a “big bang” rewrite
- Tooling (Jest, ESLint, TypeScript) benefits from simplified module resolution
However, package authors should be aware that top-level await in their exports will break require() compatibility.
Conclusion
The stabilization of require(esm) in Node.js 22/23 bridges the long-standing gap between CommonJS and ES Modules. CJS codebases can now consume ESM-only packages directly, eliminating the async wrapper tax that previously blocked incremental migration.
For teams maintaining large CJS applications, this is a game-changer: adopt modern ESM dependencies today, migrate the codebase module by module, and take full advantage of the ESM ecosystem without sacrificing the synchronous simplicity of require().
