Back to core workflows Fix dependency resolution Tune package metadata Validate before publishing

Loading ESM from CommonJS with require(esm)

For years, a CommonJS file could only load an ES module asynchronously through import(), which forced whole call chains to become async and pushed many library authors into shipping dual builds. Node.js now supports require() of ES modules directly — unflagged since Node.js 22.12 and backported to Node.js 20.19. This guide explains what require(esm) can and cannot load, how it changes the choice between ESM-only and dual packages, and how to diagnose the errors that remain.

Exact symptoms and error messages

On runtimes without require(esm), or when it is disabled, requiring an ES module fails with the familiar error:

Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/esm-only-lib/index.js from /app/server.js not supported.
Instead change the require of index.js in /app/server.js to a dynamic import() which is available in all CommonJS modules.

On supported runtimes, the call succeeds, but two new failure modes appear. A module graph that uses top-level await cannot be loaded synchronously:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.

And on some 22.x and 20.x releases, a one-time warning is printed the first time it is used:

(node:48211) ExperimentalWarning: CommonJS module /app/server.js is loading ES Module /app/node_modules/esm-only-lib/index.js using require().
Support for loading ES Module in require() is an experimental feature and might change at any time

That warning is informational; the load succeeds. Later releases removed it as the feature stabilised.

Root cause analysis

The historic limitation was not arbitrary. ES modules are linked and evaluated in phases, and the specification allows any module in a graph to use top-level await, which makes evaluation potentially asynchronous. require() is synchronous and must return the module's exports immediately. Node.js therefore refused all ESM in require().

require(esm) resolves the conflict by loading the ES module graph synchronously when it can prove that no module in the graph awaits at the top level. If the graph is fully synchronous, require() returns the module namespace object. If any module contains top-level await, it throws ERR_REQUIRE_ASYNC_MODULE instead of blocking. The broader interop model is covered in ESM and CJS Interoperability.

What require() does with an ES module A decision chain checking Node version, whether the graph has top-level await, and whether the module has a default export, to predict the result of require. Node older than 20.19 / 22.12? ERR_REQUIRE_ESM use import() or upgrade yes Top-level await anywhere in the graph? ERR_REQUIRE_ASYNC_MODULE the graph cannot evaluate synchronously yes no Does it export 'module.exports'? That value is returned opt-in interop for default-export packages yes no Namespace object named exports plus default, like import * as ns no
require(esm) succeeds for any synchronous ESM graph; top-level await is the one hard limit.

The value require() returns is the module namespace — the same object import * as ns from 'pkg' would produce. Named exports appear as properties, and the default export appears as ns.default. That detail matters for packages whose main export is a default: require('esm-lib') returns { default: fn, ... }, not fn. Library authors can opt into CommonJS-style behaviour by exporting a binding literally named "module.exports":

// esm-lib/index.js
export default function greet(name) { return `hi ${name}`; }
export { greet as 'module.exports' };

With that export in place, require('esm-lib') returns greet directly.

Resolution and configuration patch

As an application or CommonJS library author

  1. Check your runtime floor. require(esm) is available without flags on Node.js 20.19+ and 22.12+, and on every release from 23 onwards. On 22.0–22.11 it needs --experimental-require-module.
  2. Replace import() workarounds where they force needless async. Code such as:
// Before: async only because the dependency is ESM
async function loadParser() {
  const { parse } = await import('esm-only-parser');
  return parse;
}

can become synchronous:

// After: Node.js 20.19+ / 22.12+
const { parse } = require('esm-only-parser');
  1. Handle default exports explicitly. If the dependency's main export is a default and it does not use the "module.exports" export name, read .default:
const mod = require('esm-default-lib');
const createClient = mod.default ?? mod;
  1. Keep import() for graphs with top-level await. Run with --experimental-print-required-tla to see which module awaits; if it is a dependency, you cannot change it, and the dynamic import stays.

As a package author deciding on ESM-only

require(esm) makes ESM-only packages far more practical, because CommonJS consumers on supported runtimes can use them without changes. It does not help consumers on Node.js 18 or early 20.x and 22.x releases, older bundlers, or tools that implement their own require, such as some Jest configurations.

Dual build versus ESM-only once require(esm) is available Compares shipping dual CJS and ESM builds against ESM-only on consumer reach, maintenance cost, dual-package hazard and type complexity. Dual CJS + ESM ESM-only Node 18 / early 20 and 22 works import() only Node 20.19+ / 22.12+ works works via require(esm) Dual package hazard possible two instances impossible Build and types complexity two outputs, two type sets one of each Top-level await allowed breaks the CJS build breaks require(esm) users
ESM-only is viable once your supported Node.js floor includes require(esm); dual builds remain the choice for wider reach.

A pragmatic rule: if your engines.node field already requires 20.19 or later, ship ESM-only and avoid top-level await in the package's entry graph. If you must support older runtimes, keep the dual build described in How to Configure package.json for Dual Modules — and watch for the dual package hazard.

How exports conditions interact with require(esm)

When a package has an exports map, require() still matches the require condition first. A dual package with "require": "./dist/index.cjs" keeps serving its CommonJS build to require() callers even on new runtimes — require(esm) never kicks in. It only applies when the matched target is an ES module: an ESM-only package whose map lists import and default but no require, or a package with no exports whose main points at ESM.

Node.js also added a module-sync condition that lets a package tell require(esm)-capable runtimes to load an ES module synchronously while older runtimes fall back to a CommonJS file:

{
  "exports": {
    ".": {
      "module-sync": "./dist/index.js",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}

On a runtime that understands module-sync, both import and require resolve to the single ESM file, eliminating the dual-package hazard; older runtimes use the require entry. It is a transitional tool — useful for widely used libraries that want to move towards one module instance without dropping older consumers.

require(esm) availability across Node.js releases Timeline from the experimental flag in 22.0 through unflagged support in 22.12 and 20.19 and later releases where it is standard. 22.0 --experimental-require-modul e 23.0 enabled by default 22.12 unflagged on 22 LTS 20.19 backported to 20 LTS 24 and later standard behaviour
Your supported Node.js range decides whether consumers can rely on require(esm) or still need import().

Test runners, bundlers and other loaders

require(esm) is a feature of Node's own CommonJS loader. Anything that replaces that loader behaves differently. Jest's module system implements require itself and does not load ESM through it; in Jest, requiring an ESM-only package still fails unless the package is transformed or Jest runs in native ESM mode. Bundlers such as webpack and esbuild have always been able to mix require and ESM at build time, so the feature changes nothing for bundled code. TypeScript models the behaviour for module: nodenext from TypeScript 5.8 onward, so a CommonJS TypeScript file can import types from an ESM-only package without errors under recent compilers.

Worked example: removing an async wrapper from a CommonJS server

An Express server written in CommonJS depends on a Markdown renderer that went ESM-only in its latest major. To upgrade, the team had wrapped the renderer in an async loader, which rippled outward: the route handler became async, the template helper that called it became async, and a synchronous configuration function had to be split in two. The code worked, but every caller carried an await that existed only because of the module format.

After raising the deployment image to Node.js 22.12, the loader collapses to one line: const { marked } = require('marked'). The helper and configuration function return to being synchronous, and the diff removes more code than it adds. Two checks make the change safe. First, node --experimental-print-required-tla -e "require('marked')" confirms that nothing in the renderer's graph uses top-level await. Second, the Dockerfile's base image and the engines.node field both move to >=22.12, so a developer running an older local Node.js gets a clear warning rather than ERR_REQUIRE_ESM.

The same pattern appears in build tooling. Configuration files such as webpack.config.js or .eslintrc.cjs that previously had to use import() for ESM-only plugins can now require them, which removes the need to convert the whole configuration to an async function.

Risks to keep in mind

require(esm) is a compatibility bridge, not a new module system, and a few edge cases remain. Cycles between CommonJS and ES modules that were already fragile with import() can surface as ERR_REQUIRE_CYCLE_MODULE when one side is loaded synchronously while the other is still evaluating. Loader hooks registered with module.register() still apply to modules loaded through require(esm), so hooks that assumed asynchronous loading may need updating. And because a dependency can add top-level await in any release, a patch update can turn a working require() into ERR_REQUIRE_ASYNC_MODULE; lockfiles and a CI job that loads your critical ESM dependencies with require() catch that before production does.

CLI validation and debug commands

# Is require(esm) available on this runtime?
node -p "process.features.require_module"

# Try the load directly
node -e "const m = require('esm-only-lib'); console.log(Object.keys(m))"

# Find the module responsible for top-level await
node --experimental-print-required-tla -e "require('esm-only-lib')"

# Silence the experimental warning on releases that still print it (not recommended long-term)
NODE_NO_WARNINGS=1 node server.js

process.features.require_module is true when the feature is enabled, which makes it a clean runtime check in library code that must pick a strategy.

Prevention and CI/CD guardrails

  • Test your package against the oldest Node.js release you support. A version matrix, as in Running a Node.js Version Matrix for a Library, shows whether consumers on older runtimes can still load it.
  • Keep top-level await out of library entry graphs. One awaited import in a leaf module makes the whole package unusable from require().
  • Export "module.exports" for default-export ESM libraries so CommonJS consumers get the value they expect.
  • State your floor. Put the minimum Node.js version in engines and in the README when you rely on require(esm).

Frequently Asked Questions

Does require(esm) make dual packages obsolete? For packages that support only Node.js 20.19 and later, largely yes. Dual builds remain useful for older runtimes, for tools with their own module loaders, and for packages that must use top-level await.

Why does require() return an object with a default property? Because it returns the module namespace, exactly like import * as ns. Read .default, or ask the author to add the "module.exports" export name so require() returns the default directly.

Can I use require(esm) in a published CommonJS library? Yes, if your declared Node.js floor supports it. Consumers on older runtimes will get ERR_REQUIRE_ESM, so document the requirement and set engines.node accordingly.

Related

ESM and CJS Interoperability