ERR_REQUIRE_ASYNC_MODULE: why require() won't wait for you

Node throws ERR_REQUIRE_ASYNC_MODULE when require() hits an ESM module with top-level await in its graph. Here's the whole mechanism and the fix that holds up.

node.js commonjs, esm top-level-await module-resolution require-esm JavaScript npm
Bharath G
Reading Progress

On This Page

Error [ERR_REQUIRE_ASYNC_MODULE], exactly as Node prints it

node:internal/modules/esm/module_job:450
      throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
      ^

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.
  From /app/server.js
  Requiring /app/node_modules/some-pkg/dist/index.js
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:450:13)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:435:47)
    at loadESMFromCJS (node:internal/modules/cjs/loader:1536:24)
    at Module._compile (node:internal/modules/cjs/loader:1687:5)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)
    at Module.require (node:internal/modules/cjs/loader:1463:12) {
  code: 'ERR_REQUIRE_ASYNC_MODULE'
}

Node.js v22.22.2

If that's your terminal right now, skip to the fix. If you want to actually understand it so it never bites you again on any Node line, keep reading, because this one has a genuinely weird shape and most write-ups on it stop about a third of the way in.

This is a Node-only error. There's no browser equivalent, no Firefox or Safari wording to compare, because browsers don't have require() at all. Every occurrence of this comes from CommonJS code, somewhere, calling require() on a file that Node resolves as an ES module containing a top-level await.

I hit this two Fridays ago when a routine npm update in a CI job started failing a test suite that hadn't been touched in months. Nothing in the diff explained it. The failing package was three require() calls deep, and the trace didn't so much as glance at my code.

Reproduce it with three files

javascript

// esm-lib/index.js
const config = await Promise.resolve({ ready: true });
export function getConfig() {
  return config;
}

json

// esm-lib/package.json
{ "name": "esm-lib", "type": "module" }

javascript

// app.js (CommonJS, no "type": "module" anywhere above it)
const { getConfig } = require('./esm-lib/index.js');
console.log(getConfig());

Run it:

bash

node app.js

That's it. Three files, no bundler, no test runner, no framework. esm-lib/package.json marks the folder as ESM, esm-lib/index.js has a top-level await, and app.js tries to pull it in with plain require(). I ran this exact tree on Node 22.22.2 and got the block at the top of this page, byte for byte except for the timestamp on the version line.

Take the await out of esm-lib/index.js and the same require() just works. That's the part that throws people: this isn't the old "you can't require an ES module" problem. Node has been able to require() plain ES modules for a while now. It's specifically the await that breaks it.

Does your Node version even hit this?

I tested this by hand on the binaries I had lying around rather than trusting old blog posts, because the exact wording and the exact triggering version matter here.

Versionrequire(esm) at all?This exact error?Notes
20 (Iron)Yes, from 20.19.0YesBackported by demand; 20 hit end-of-life on April 30, 2026, so it's already off the support table as of this writing.
21No, not even behind a flagNo; you get ERR_REQUIRE_ESM insteadI tried --experimental-require-module on 21.7.3 and Node rejected it outright: "bad option." The feature landed after 21 was already end-of-life (it died in April 2024).
22 (Jod)Yes, from 22.12.0YesCurrently Maintenance LTS, supported through April 30, 2027. This is where most people report the error, because 22.12 is where require(esm) went from experimental to on-by-default.
24 (Krypton)Yes, inherited from birthYes (per the same loader code; I didn't have a 24 binary on hand to run this one myself, so take this row as documented rather than personally verified)Active LTS right now, moving to Maintenance in late October 2026.
26YesYes, plus extra detailShipped as Current on May 5, 2026. Since v26.5.0 (released July 8, 2026), the error also carries requireStack and topLevelAwaitLocations as non-enumerable properties. You won't see them in a plain console.log(err), but code can read err.requireStack directly. I confirmed those properties are absent on 20.20.2 and 22.22.2; they're new to the 26 line.

What changes next: 26 becomes Active LTS in late October 2026, and 27 arrives as the first release under Node's new yearly cadence: one alpha in October, one major every April, every line eventually promoted to LTS, roughly 36 months from Current to end-of-life. None of that changes this error's behavior. Once a runtime supports require(esm) at all, a top-level await in the required graph throws this, full stop, on every version going forward. There's no flag that will ever make require() actually wait.

The short version

require() returns a value. Synchronously. On the same tick. It has worked that way since 2009 and every piece of CommonJS code on npm assumes it. A module with a top-level await doesn't have a value yet at the point require() needs to return one. It has a promise that resolves later, on some future microtask. Node can't hand you the finished module and also not wait for it. So instead of returning something wrong (or hanging forever), it throws.

That's the whole mechanical cause. Everything past this point is either "why the engine draws the line exactly here" or "what to do about it," because the short version, while true, doesn't actually tell you which of your forty dependencies is the guilty one.

Inside ModuleJobSync: how the loader enforces this

Before require(esm) existed, none of this mattered because CommonJS simply couldn't touch ESM at all. You got ERR_REQUIRE_ESM and that was final. What changed starting with 20.19.0 and 22.12.0 is that Node's CommonJS loader gained the ability to synchronously instantiate and evaluate an ES module graph on require(), using an internal ModuleJobSync (that's the class name right there in the stack trace) instead of the normal async ModuleJob.

Synchronous instantiation is possible because V8 module records don't actually require an event-loop turn to run, as long as nothing in the graph suspends. Ordinary ESM evaluation (imports, exports, top-level const/function, even synchronous side effects) all happens in one pass. What can't happen synchronously is a Promise that hasn't settled yet, and a top-level await is exactly that: the module's own evaluation is suspended on a promise, and V8 has no synchronous way to force that promise to resolve early.

So the loader does the honest thing. Before committing to the synchronous path, it walks the module's dependency graph (using requestedModules, recursively, which is why a top-level await three packages deep still trips this even when your own file doesn't await anything) and checks whether any module in that graph has an unresolved top-level await. If it finds one, it throws ERR_REQUIRE_ASYNC_MODULE before your code sees a half-built module. ModuleJobSync.runSync, the very first frame in the trace, is that check refusing to proceed.

One thing worth knowing here, since it changes what you'll see in your own trace: Error.stackTraceLimit defaults to 10, and this error typically burns every one of those ten frames on internal loader machinery: ModuleJobSync.runSync, loadESMFromCJS, Module._compile, and so on. Run a require chain that's even two files deep and your own call site gets pushed clean off the end of the trace. I only noticed this because I ran the same repro with --stack-trace-limit=20 out of habit and watched two more frames appear that weren't there before, including the actual line in my own file that called require(). The default trace looked complete. It wasn't.

Two wrong turns before finding the real top-level await

First guess: this is the old ESM/CJS interop problem again. I've hit ERR_REQUIRE_ESM plenty of times before, so I assumed this was that, with new paint. I went looking through my own source for a stray require() of a .mjs file or a misconfigured "type" field. Grepping my own repo turned up nothing, because the offending require() wasn't in my code at all. It was inside a dependency, two levels down, and I'd been looking in the wrong codebase entirely.

Second guess: it's a stale Node version issue, so pin an older Node and move on. Downgrading to a Node minor from before 22.12 does make the immediate symptom disappear, because you fall back to the pre-require(esm) behavior where CommonJS can't touch ESM at all. But that's not a fix, it's a time machine. You lose require(esm) entirely for every other package that depends on it working, and you've only delayed the moment you have to actually deal with the one broken dependency, probably at a worse time.

What actually worked: reading the error's own "From" and "Requiring" lines, which I'd been skimming past because the internal stack frames looked more important. They aren't. "From" names the file that called require() (often a dependency's own internal file, not yours). "Requiring" names the exact ES module Node refused to load synchronously. Between those two lines and one run with --experimental-print-required-tla, you get the complete picture without guessing:

bash

node --experimental-print-required-tla app.js
Error: unexpected top-level await at file:///tmp/repro-min/esm-lib/index.js:1
const config = await Promise.resolve({ ready: true });
               ^

That output points at the exact file, line, and column of the await that's causing the failure. In a real project, that's usually somewhere under node_modules, in a package that shipped a top-level await in its ESM build. This is precisely what happened with a well-known caching library that added a top-level await to its ESM output in a minor version bump: nothing about your code changed, a transitive dependency updated, and every require() chain that touched it started throwing this error under test runners that load fixtures through jsdom. The fix on that side ended up being to replace the top-level await with a synchronous default plus a lazy async update, exactly the pattern in the "better design" section below.

Ranked by how often each one turns out to be the actual cause, in my experience and in the handful of public bug reports I checked while writing this: a transitive dependency shipping new top-level await in an ESM build is by far the most common trigger, ahead of "I wrote a top-level await in my own package's entry point," which is rarer because most people writing a library entry point already know better.

Fixing ERR_REQUIRE_ASYNC_MODULE, from quick patch to real fix

Fastest: pin the offending dependency back a version. If a dependency bump introduced the top-level await, your lockfile from yesterday didn't have this problem. npm ls <package> or a quick look at the dependency's changelog tells you which version added it; an overrides (npm) or resolutions (yarn) entry pins it while you sort out the real fix. This buys time. It is not the real fix.

Direct: stop using require() for that one import. Dynamic import() handles asynchronous module graphs fine, because it was designed to be asynchronous from day one.

javascript

// before
const { getConfig } = require('./esm-lib/index.js');
console.log(getConfig());

// after
const { getConfig } = await import('./esm-lib/index.js');
console.log(getConfig());

The catch: top-level await only works in an ES module or at the Node REPL. If app.js itself is CommonJS, you can't just sprinkle await at the top of it. Wrap the call in an async function instead:

javascript

async function main() {
  const { getConfig } = await import('./esm-lib/index.js');
  console.log(getConfig());
}

main();

Escape hatch, not a fix: --no-experimental-require-module. This turns off synchronous require(esm) entirely, which makes the specific top-level-await case disappear because now every require() of an ES module fails the same old ERR_REQUIRE_ESM way it always did before 22.12. That's a strictly worse error for anything that isn't the async case, so I wouldn't ship this as a permanent setting. It's fine for confirming that require(esm) is indeed what's involved.

If it's your own package: drop the top-level await. Convert it into an exported async initializer, or a synchronous default that gets asynchronously refreshed later, the same shape the caching-library fix above used. This is the only option on this list that fixes it for every consumer at once instead of every call site individually, which is exactly why I'd ship it over the await import() patch whenever I control the source.

Shipping a module-sync export instead of hoping nobody notices

If you're the one authoring the package, the real answer isn't "never use top-level await," it's "don't make your synchronous consumers pay for it." Node's conditional exports support a module-sync condition specifically for this: it matches regardless of whether the consumer used import, import(), or require(), but the file it points to is expected to be an ES module with no top-level await in its graph.

json

{
  "name": "some-pkg",
  "exports": {
    "import": "./dist/index.mjs",
    "module-sync": "./dist/index-sync.mjs",
    "require": "./dist/index.cjs"
  }
}

That gives synchronous callers (anyone using require(), including transitively) a build that was built to be required, while your async-native consumers still get whatever the full-featured build does. It's more work than just publishing one ESM build and calling it done, and I get why most maintainers skip it. But "it works until someone requires you synchronously" is exactly the bug this article is about, and the maintainer decides whether their users hit it or not.

On the consuming side, the version-proof shape is simpler: never let a require() call be the only path to a dependency you don't control the release cadence of. If it's optional or lazy-loaded, load it with import() behind an async function. If it's a hard dependency loaded at startup, make your own entry point async too, so a future minor bump in some transitive package can't take your whole process down with an error message that doesn't even mention your code.

javascript

// config-loader.js: CommonJS, but never assumes require() can reach async ESM
let cached;

async function loadConfig() {
  if (!cached) {
    const mod = await import('some-pkg');
    cached = mod.getConfig();
  }
  return cached;
}

module.exports = { loadConfig };

Catching a top-level-await regression before it reaches main

There's no lint rule that flags "a dependency might ship top-level await in its ESM build next month," and I don't think that's a gap worth pretending is solved. What actually catches this is process, not static analysis:

A CI job that runs your real entry points, not just unit tests, against every Node line you claim to support (22, 24, and 26 at minimum right now) turns this into a red build the moment a dependency update introduces it, instead of a production incident. Committing your lockfile and running npm ci rather than npm install in CI keeps a silent dependency bump from ever reaching a branch unreviewed. If you're on Renovate or Dependabot, group runtime-adjacent dependencies so a bump like this shows up as its own PR with its own test run, rather than buried in a batch of forty version bumps nobody reads closely.

And if you maintain a package that touches this: arethetypeswrong and publint both check exports maps for shape problems. Neither one currently flags a missing module-sync condition specifically, so that part is still a manual review question when you add a top-level await to a build.

What to remember about ERR_REQUIRE_ASYNC_MODULE

This is a Node-only error, and it has existed in some form since require(esm) itself, first in 20.19.0 and 22.12.0.

It fires when a require() call resolves to an ES module whose graph contains an unresolved top-level await, anywhere in that graph, not just at the top level of the file you named.

The error's own "From" and "Requiring" lines usually tell you exactly which two files are involved without any further digging. --experimental-print-required-tla gets you the exact line and column of the await when they don't.

Default stack traces truncate at 10 frames and this error's internal frames eat most of them, so your own call site can vanish from the trace entirely. Raise --stack-trace-limit before you assume the trace is telling you the whole story.

The durable fix is architectural: never treat a dependency you don't control as safe to load with a bare require() if there's any chance it goes async in a future release. await import() at the call site, or a module-sync export on the library side, both outlive whatever Node version you're on today.

node.jscommonjs,esmtop-level-awaitmodule-resolutionrequire-esmJavaScriptnpm

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments