Featured image of post Node.js Stable Support for Requiring ESM Modules Featured image of post Node.js Stable Support for Requiring ESM Modules

Node.js Stable Support for Requiring ESM Modules

Exploring node resolution changes that support loading ES modules via require(), simplifying codebase migrations.

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:

  1. Node identifies the module as ESM (via package.json "type": "module" or .mjs extension)
  2. It checks if the ESM graph contains top-level await — if yes, it throws a ERR_REQUIRE_ASYNC_MODULE error
  3. If no top-level await is present, it synchronously parses and evaluates the ESM graph
  4. 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

FeatureSupported in require(esm)Notes
Named exportsYesDirect property access
Default exportYesReturned as .default or directly for single export
Live bindingsNoRequire captures values, not live bindings
Cyclic dependenciesPartialWorks for simple cycles, may fail for complex graphs
Top-level awaitNoThrows ERR_REQUIRE_ASYNC_MODULE
Dynamic import()YesWorks 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 of require()
  • 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().