Top-Level await in Published Packages
Why one top-level await makes a package effectively ESM-only for CommonJS consumers, how to detect it in a build, and the deferred-initialisation pattern that replaces it.
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
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.
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 therequire()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
requirepath. - The error code changed across Node versions. Older releases report
ERR_REQUIRE_ESMfor the same situation, which sends people looking for a different cause. - TypeScript will not warn you. Top-level
awaitcompiles 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.
Related
- Understanding ESM vs CJS Module Formats — the evaluation model this constraint comes from.
- Fixing require() Errors in Pure ESM Packages — what consumers see when a package is ESM-only, and how to help them.
- Mastering the package.json Exports Field — removing a condition honestly rather than shipping one that fails.