A dual-format package builds cleanly, and every CommonJS consumer fails on first require:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with
top-level await. Use import() instead.
    at Module._compile (node:internal/modules/cjs/loader:1364:14)

One await at module scope, in your source or anywhere in your import graph, is the whole cause. It is worth treating as a distribution decision rather than a syntax choice, because that is what it is.


Root Cause

require() is synchronous by specification: it must return module.exports before the calling statement finishes. A module whose evaluation contains a top-level await cannot complete synchronously, so there is nothing for require to return.

Node.js 22 can require() an ESM module — but only when the entire graph being loaded is free of top-level await. That is not a temporary limitation to be lifted later; it is the same synchrony constraint restated. The consequence for a library is that the feature and a working require condition are mutually exclusive.

The reach of the rule surprises people. It applies to the graph, not the file: a dependency four levels down that awaits at module scope makes your package unloadable by CommonJS consumers even though none of your own files use the syntax.


Minimal Reproduction

// src/config.ts
const raw = await fetch("https://config.example.com/defaults.json");   // top-level await
export const defaults: Record<string, string> = await raw.json();
// src/index.ts
import { defaults } from "./config.js";
export function getDefault(key: string): string | undefined { return defaults[key]; }
node --input-type=module -e "await import('@scope/my-library')" && echo "esm ok"
node --input-type=commonjs -e "require('@scope/my-library')" 2>&1 | head -2
esm ok
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await.

What the Graph Rule Means

One await, the whole graph The entry module imports a client module which imports a config module containing top-level await. Because the property is transitive, the entry point itself becomes asynchronous and require cannot load any of it. The property is transitive, and travels upward index.mjs now async, unloadable client.mjs async by inheritance config.mjs the one await A dependency can introduce this without any change to your own source — which is why the check belongs in the build, not in review

Step-by-Step Fix

1. Replace module-scope awaiting with a cached lazy initialiser

- const raw = await fetch("https://config.example.com/defaults.json");
- export const defaults: Record<string, string> = await raw.json();
+ let cached: Promise<Record<string, string>> | undefined;
+
+ export function loadDefaults(): Promise<Record<string, string>> {
+   cached ??= fetch("https://config.example.com/defaults.json").then((r) => r.json());
+   return cached;
+ }

The work happens once, on first call, and the module evaluates synchronously. A consumer awaits at a point they control — and can now catch a failure, which a module-scope await made impossible.

2. Or export an explicit init function where setup is genuinely required

let ready: Promise<void> | undefined;

export function init(options: InitOptions = {}): Promise<void> {
  ready ??= (async () => {
    const raw = await fetch(options.configUrl ?? "https://config.example.com/defaults.json");
    Object.assign(defaults, await raw.json());
  })();
  return ready;
}

export function getDefault(key: string): string | undefined {
  if (!ready) throw new Error("call init() before getDefault()");
  return defaults[key];
}

The explicit error is better than the silent empty object a module-scope await would have produced on failure.

3. Detect the syntax in the build, including transitively

# your own emitted output
grep -rnE '^\s*(const|let|var)?\s*.*\bawait\b' dist/*.mjs \
  | grep -vE 'async|=>|function' && echo "possible top-level await" || echo "ok: none in own output"

# and the graph, authoritatively
node --input-type=module -e "
  import { createRequire } from 'node:module';
  try { createRequire(import.meta.url)('@scope/my-library'); console.log('ok: require works'); }
  catch (e) { console.log('FAIL:', e.code); }
"

The second check is the one that matters: it exercises the real graph, dependencies included, and answers the only question that counts.

Three shapes for asynchronous setup Module-scope await blocks require entirely. An exported init function moves the await to the consumer. Lazy initialisation inside the functions that need the resource requires nothing of the consumer at all. Same work, three places to put it module scope await at the top level require is impossible errors are uncatchable ESM-only, by implication exported init() consumer awaits once both formats load fine failures are catchable costs one API call lazy, cached nothing for the consumer to remember work happens on first use usually the best default

HAZARD PREVENTION

Symptom: require() of your package starts failing after a dependency upgrade, with no change to your own code.

Root cause: The upgraded dependency introduced top-level await, and the property propagated up your graph.

Fix: Pin the dependency and open an issue upstream, or isolate it behind a dynamic import() inside a function so it is no longer part of the statically-loaded graph. Add the require() smoke test to CI so the next occurrence fails your build rather than a consumer’s.

4. If you keep it, drop the require condition honestly

   "exports": {
     ".": {
       "types": "./dist/index.d.ts",
       "import": "./dist/index.mjs",
-      "require": "./dist/index.cjs",
       "default": "./dist/index.mjs"
     }
   }

A require condition that cannot work is worse than no require condition: with it, consumers get a runtime error deep in their application; without it, they get a clear resolution error at install or build time and know immediately that the package is ESM-only.


Verification

d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent

node --input-type=module -e "await import('@scope/my-library'); console.log('esm ok')"
node --input-type=commonjs -e "require('@scope/my-library'); console.log('cjs ok')"

Expected: both lines print. If the package is deliberately ESM-only, expect the second to fail with a resolution error rather than ERR_REQUIRE_ASYNC_MODULE.


Edge Cases / Gotchas

  • Bundlers can hide it in development and expose it in production. A dev server that serves ESM directly never exercises the CommonJS path, so the failure appears only in a consumer’s production build.
  • await import() inside a function is fine. The restriction is on module evaluation; a dynamic import in a function body keeps the graph synchronous.
  • Test runners differ. Some load your ESM build regardless of how a consumer would, so a passing test suite proves nothing about the require path.
  • The error code changed across Node versions. Older releases report ERR_REQUIRE_ESM for the same situation, which sends people looking for a different cause.
  • TypeScript will not warn you. Top-level await compiles fine under an ESM module target; the constraint is a runtime one, so only a runtime check catches it.

Frequently Asked Questions

Why can’t require() load a module with top-level await?

require() is synchronous by specification and must return exports before the calling line completes; a module containing top-level await cannot finish evaluating synchronously.

Does the restriction apply to the whole module graph?

Yes — any module reachable from the entry point makes the whole graph asynchronous, so a deep dependency can cause it without any of your own files using the feature.

Can a bundler compile top-level await away?

Only by changing semantics — wrapping the module so consumers must await its exports, which is a different public API.

What replaces it for one-time asynchronous setup?

An exported async init(), or lazy initialisation with a cached promise inside the functions that need the resource.

Is it ever acceptable in a published package?

Yes, in a package that has deliberately chosen to be ESM-only and says so. The problem is the combination of using it and shipping a require condition that cannot work.



↑ Back to Understanding ESM vs CJS Module Formats