ERR_REQUIRE_ASYNC_MODULE: why require() of an ESM package still fails on Node 22+

Node.js · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: Node 22.12+ lets CommonJS require() ESM packages, but any top-level await in the graph throws ERR_REQUIRE_ASYNC_MODULE. Here's how to find it and get unblocked.

What Changed

If you just upgraded to Node 22.12+ or 23+ specifically so you could require() an ESM-only package, and you got ERR_REQUIRE_ASYNC_MODULE instead, the feature is working as designed — you hit its one hard limit. Node now supports require() of ES modules from CommonJS, unflagged: it shipped behind --experimental-require-module in Node 22.0, was turned on by default in 22.12 (LTS) and 23.0, and is on in 24.x. But the support is synchronous-only. The moment any module in the imported graph uses top-level await, require() throws:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead.

There is a second, rarer sibling: ERR_REQUIRE_CYCLE_MODULE, when the CJS→ESM→CJS cycle can't be resolved synchronously.

The Old Way vs The New Way

Before, require() of any ESM file threw ERR_REQUIRE_ESM, so CJS code wrapped everything in a lazy dynamic import and became async all the way up:

// Old: CJS consuming an ESM-only dep
let chalkPromise;
function getChalk() {
  chalkPromise ??= import('chalk').then(m => m.default);
  return chalkPromise;
}

module.exports.log = async (msg) => {
  const chalk = await getChalk();   // every caller becomes async
  console.log(chalk.green(msg));
};

Now, if the dep's graph is fully synchronous:

// New: Node >= 22.12, CJS file
const chalk = require('chalk');        // returns the module NAMESPACE
console.log(chalk.default.green('ok')); // default export is NOT unwrapped

module.exports.log = (msg) => console.log(chalk.default.green(msg));

Two gotchas visible in that snippet: you get the namespace object, so export default lands on .default (a very common second stumble right after ERR_REQUIRE_ASYNC_MODULE), and this only compiles-and-runs if nobody in the graph awaits at module scope.

Why It Was Added

The ecosystem had bifurcated. Library authors published ESM-only, and every CJS consumer — including the enormous population of Jest setups, CLI tools, and Lambda handlers with "type": "commonjs" — had to either dual-publish, bundle, or make their public API async. "Async-poisoning" a synchronous API just to read a dependency is a real design regression: it changes call signatures, breaks constructors, and forces initialization ordering hacks.

require(esm) removes that for the majority case, because most modules genuinely have no top-level await. The restriction exists because require() must return a value on the same tick; a graph with TLA cannot finish evaluating before the call returns, so there is no honest value to hand back. Node chose to throw loudly rather than return a half-initialized namespace.

How It Works Underneath

ESM loading has three phases: resolve/load, link (instantiate), and evaluate. Linking is synchronous; only evaluation can be async, and only if a module has top-level await. So Node can load the graph, inspect it, and then decide whether a synchronous evaluation is possible.

flowchart TD
    A["require('pkg')"] --> B[Resolve specifier]
    B --> C{Detected format}
    C -->|CommonJS| D[Normal CJS wrapper, return module.exports]
    C -->|ES module| E[Load + link whole graph synchronously]
    E --> F{Any module in graph<br/>has top-level await?}
    F -->|Yes| G["throw ERR_REQUIRE_ASYNC_MODULE"]
    F -->|Cycle back into CJS| H["throw ERR_REQUIRE_CYCLE_MODULE"]
    F -->|No| I[Evaluate graph synchronously]
    I --> J[Return frozen module namespace<br/>default export under .default]

Because the check is on the whole linked graph, a top-level await in a transitive dependency ten levels down is enough. That is why the thrown stack trace usually points at your own require() line and tells you nothing useful about the culprit. Node ships a flag for exactly this:

node --experimental-print-required-tla app.js
# Prints each module in the required graph that uses top-level await,
# with the specifier and location, then still throws.

Also note the format detection step: Node decides ESM vs CJS from "type" in the nearest package.json, the .mjs/.cjs extension, exports conditions, or syntax detection. A package that resolves to its CJS condition under require won't take the ESM path at all — which is why the same specifier can work in one project and throw in another.

Should You Adopt It Yet

Yes for application code on Node 22.12+ / 24 LTS, with caveats:

Migration Notes

  1. Pin the floor: "engines": { "node": ">=22.12" }. Anything lower throws ERR_REQUIRE_ESM instead.
  2. Grep for the old workarounds you can now delete: await import(, import( inside .cjs/CJS files, and createRequire(.
  3. Convert one require at a time and run the real entry point under the production Node version. Add --experimental-print-required-tla to your CI smoke command so a TLA regression is reported by file name.
  4. Fix the .default interop deliberately rather than with ?? m. Write const lib = require('pkg').default ?? require('pkg') only at boundaries you own; inside your code, reference the namespace explicitly so the shape is obvious.
  5. If you hit ERR_REQUIRE_CYCLE_MODULE, the fix is structural: break the CJS→ESM→CJS loop by extracting the shared piece into a leaf module with no back-edges.
  6. For anything that must stay loadable by older Node or by bundlers you don't control, keep the async import path behind a lazy initializer — it costs one await at startup and immunises you against a dependency adding top-level await later.

Key takeaway: require(esm) works only when the entire imported graph is synchronous — one top-level await anywhere throws ERR_REQUIRE_ASYNC_MODULE, and `--experimental-print-required-tla` tells you which file to blame.

Real-world challenge

A CommonJS Express service upgraded from Node 20 to Node 22.14 so it could `require()` an ESM-only telemetry SDK instead of juggling dynamic imports. It boots fine locally with `node --test` but crashes in production with `Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await`. The stack trace points at your own `src/tracing.js`, which just does `require('@vendor/otel-sdk')`. The vendor SDK's source has no `await` at module scope that you can find.

Diagnose

The stack frame is the requirer, not the offender. The top-level await is somewhere deeper in the linked graph. Ask Node to name it:

node --experimental-print-required-tla src/server.js
# prints the specifier + line of every TLA module in the required graph

Typically it surfaces a transitive dep doing something like const cfg = await loadRemoteConfig() or a conditional await import() at module scope.

Why it passed locally: your test entry was ESM (or you were on the dynamic-import path), so the graph was loaded through import, where TLA is legal.

Fix, in order of preference

  1. Make the boundary async instead of sync — keep the dynamic import you removed:
// tracing.js (CJS)
let sdk;
module.exports.init = async () => {
  sdk ??= await import('@vendor/otel-sdk'); // TLA is fine here
  return sdk.default ?? sdk;
};
  1. Await it once during startup, before anything needs it, so the rest of the app stays sync.
  2. File an issue upstream: TLA in a library entry point silently blocks all CJS consumers.

Guardrail: add a smoke test that runs node -e "require('./src/tracing.js')" in CI on the exact Node version production uses, so an upstream bump reintroducing TLA fails the build instead of the deploy.