Webpack 5.111.0 shipped on 14 September 2026 with two fixes to the same question: when an async ESM module blows up after its first await, does anybody find out? One of them is on by default. The other sits behind output.strictModuleErrorHandling, a flag that has defaulted to false since webpack 5.25. The release post lists them nine lines apart without saying they are two halves of one bug.
Short version: after upgrading, an async ESM entry that rejects will now surface the rejection to whoever imported your bundle, if and only if your resolved target supports top-level await. An async module that fails inside a circular import still settles its peers as fulfilled, and still hands them out half-initialised, unless you turn the flag on yourself.
The default build now awaits your entry
PR #21915 by @alexander-akait added output.environment.topLevelAwait and changed the ESM bootstrap emitted by lib/javascript/JavascriptModulesPlugin.js. A single async entry gets awaited; several get wrapped in Promise.all(). Before that, the bootstrap returned the module object while evaluation was still in flight, so the importer saw a successful import of a bundle whose setup had never finished.
Nobody has to write new code to be affected. If you ship output.module: true and any entry does config loading, WASM instantiation, or a remote feature-flag fetch at the top level, your behaviour changes on a patch-range bump. It changes in the direction of surfacing errors, which is the right default and occasionally a broken deploy: the bundle that used to resolve and then misbehave now rejects at import time and takes the host page with it. That is the npm 12 shape where CI goes green over an artifact that cannot actually run, inverted. Treat the bump the way you would treat any toolchain upgrade with a dozen small traps under one version number and read the diff rather than the headline.
Your browserslist decides whether the entry gets awaited
The capability is resolved, not configured. lib/config/browserslistTargetHandler.js gates topLevelAwait on chrome 89, edge 89, firefox 89, android 89, opera 75, op_mob 63, safari 15, ios_saf 15, samsung 15, node [14, 8]. lib/config/target.js sets it for ES targets from 2022 upward, electron 12, nwjs 0.52, and unconditionally for deno and bun.
One stale safari 14 line in a .browserslistrc, the kind that survives three years because nobody wants to be the person who dropped a browser, silently keeps 5.110 semantics on a 5.111 build. You can settle this offline, against a file already on disk: build once and grep the emitted bootstrap. No await and no Promise.all around the entry call means the resolved target said no.
PR #21915 also documents its own remaining hole. The author notes that generated exports can be "unresolved or ignored for entries with dependent initial chunks." If your entry is code-split with initial chunks depending on it, the fix does not fully cover you, and the release notes do not carry that caveat forward. Neither do the docs: webpack.js.org/configuration/output/ still lists the environment object with no topLevelAwait key in it, which is why any assistant answering from pre-September training will tell you the flag does not exist and the cycle case is simply fixed.
The cycle case the default build still gets wrong
Circular imports between async modules are ordinary in large apps. A store module awaits a client, the client imports a selector from the store, and now you have a strongly connected component whose members all evaluate asynchronously. If one member throws after its first await, webpack settles each member's promise independently. The one that happened to fulfil first stays fulfilled forever and hands its namespace object to every later importer, with some exports initialised and some not. No error, no log. Teams chase that as a race condition for weeks.
The ECMAScript rule webpack was missing is about the component, not the module. An SCC records exactly one [[EvaluationError]], on its cycle root. When Evaluate() meets a module already in state EVALUATED, it redirects to [[CycleRoot]] and returns the error recorded there, so importing any member of a failed component throws the identical error object. Webpack had no representation of the component at all.
PR #21921 adds it: a cycleGroups map keyed by the smallest async member id in the component, and a markCycle call that stamps the recorded error onto every grouped module when one of them rejects. The PR is explicit that this runs only under output.strictModuleErrorHandling, "ensuring byte-identical output for default builds," and puts the cost when enabled at roughly +0.7%, about 2,126 bytes of runtime.
Byte-identical default output is a defensible position for a bundler maintainer and a bad one for you. Shared mutable state that one consumer half-initialises while every later consumer reads it as finished is a familiar shape; pnpm has its own version, where a readPackage hook edits manifests for dependencies you never matched. Two thousand bytes is cheap against a debugging session that starts with "sometimes the auth client has no token."
A skipped conformance test is a precise bug report
The case already exists upstream of webpack. test/language/expressions/dynamic-import/import-fulfilled-member-of-errored-cycle.js in test262 builds a cycle {A, B, C} where B throws after awaiting, then asserts that a later dynamic import of C rejects with the same error object recorded for the cycle root. Webpack had that test skipped. PR #21921 un-skips it and adds test/configCases/async-module/errored-cycle-member/.
A skipped conformance test states, in the vendor's own repository, exactly what the tool does not do yet. When I am evaluating whether an upgrade fixes the thing I care about, the skip list is better reading than the changelog, because it was written by someone who had no incentive to make it sound small.
experiments.deferImport was waiting on the wrong states
PR #21934 found GatherAsynchronousTransitiveDependencies in lib/runtime/MakeDeferredNamespaceObjectRuntime.js skipping two module states in one condition: evaluating and evaluating-async. Skipping evaluating is correct and prevents a deadlock, since that module is the one waiting on you. Skipping evaluating-async is wrong, because that module is suspended at an await and has to be waited on. The result under experiments.deferImport was a deferred namespace that resolved while a transitive dependency was still mid-evaluation.
Same failure as the cycle case, different code path, and this one has no flag: it is just fixed in 5.111.0. If you use deferImport, that is the reason to upgrade, not the headline feature.
Acorn is gone, and const inlining changed too
Everyone upgrading also inherits changes with no switch at all. Webpack now parses JavaScript with its own parser (PRs #22042, #21667, #22009) and neo-async went out with acorn. Syntax-error text and source locations come from different code than they did in 5.110, so anything matching on those strings is now reading a different parser's output: a lint wrapper, a log scraper, a snapshot of webpack --json diagnostics, a CI annotation rule. That is a build-tooling regression that looks like a passing build until someone reads the logs, the same way npm 12's real breakage landed after CI went green.
Cycle correctness picked up a third, unflagged fix as well: PR #22085 by @xiaoxiaojx refuses to inline const exports across sync cycles with binding reads. Output changes for builds that never used top-level await at all.
FAQ
Is output.environment.topLevelAwait something I should set by hand? Only to force the behaviour off, or on for a target webpack resolves conservatively. The normal path is to fix the browserslist query, because that value also drives the rest of the output.
Will enabling strictModuleErrorHandling break a working app? It converts silent half-initialisation into a rejection, so anything currently depending on the fulfilled-but-incomplete namespace will start failing. That is the point, and it is the reason to enable it in a branch with your integration tests rather than on a release afternoon.
Does the entry fix cover code-split entries? Not fully. PR #21915 names exports that stay "unresolved or ignored for entries with dependent initial chunks" as a known gap.
What to check before you bump past 5.110
- Build once on 5.111.0 and grep the emitted ESM bootstrap for
awaitorPromise.allaround the entry call. Nothing there means your resolvedtarget/browserslist said no top-level await and you are still running 5.110 semantics, whatever the version inpackage.jsonsays. - If step 1 came back empty and you expected otherwise, open
.browserslistrcand look for entries below the gate: safari 15, ios_saf 15, chrome 89, firefox 89, node 14.8. One of them is holding the whole build back. - Set
output.strictModuleErrorHandling: truein any app with async modules and circular imports, and budget the 2,126 bytes PR #21921 measured. Run it through integration tests first, because failures that were invisible will now be loud. - Write the regression the spec already wrote for you: a three-module cycle where the middle one throws after its first
await, then a dynamic import of a different member asserting the rejection carries the same error object. Eight lines, and it fails on a default 5.111 build, which is the proof that step 3 mattered. - If
experiments.deferImportis on, upgrade for PR #21934 specifically and re-test any deferred namespace that reaches an async transitive dependency. - Before the upgrade PR merges, diff one deliberate syntax error's output between 5.110 and 5.111 and fix whatever CI tooling matched on the old acorn strings. The parser swap ships in the same version as everything above, listed in the v5.111.0 release notes.
Comments
Be the first to comment.