# Safari 27 makes top-level await spec-compliant

> WebKit rebuilt Safari's ES module loader in pure C++, so top-level await now follows the ECMAScript spec instead of an abandoned 2016 proposal.

On 2026-09-17 WebKit shipped Safari 27.0 with what its feature post calls "an
all-new ES module loader": a pure C++ implementation of the ECMAScript
specification's own module loading algorithms, replacing an earlier implementation
built against an abandoned 2016 WHATWG Loader proposal that, in WebKit's words,
"predated top-level await entirely". The release's resolved-issues list records the
user-visible result in one line: "Fixed multiple top-level await correctness bugs
with a rewrite of the ES module loader for standards compliance." This is not a
flag, a polyfill, or a targeted patch. The previous loader, in the rewrite pull
request's own words "the current amalgam of C++ and JS", built against a proposal
abandoned a decade earlier, was replaced by a pure-C++ rewrite. WebKit files it
under "Foundations" in that post, alongside the line that explains the whole
approach: "Sometimes the best way to improve quality is to start over." Kai
Tamkun's engineering account of how it was done went up two weeks earlier, on
2026-09-02.

## What actually changed

The ECMAScript specification does not define module loading end to end. WebKit
describes the modules section of the spec as leaving some parts up to the host,
including the mechanism for fetching modules: over the network in a browser, or
from the local filesystem in runtimes such as Node.js and Bun. Safari needed an
answer for that host-defined part, and the answer it picked was the WHATWG Loader
proposal, whose specification WebKit relied on when the loader was first written.
WebKit notes that the proposal was last updated in January 2016.

That choice was defensible at the time, because, as WebKit puts it, module
execution was then purely synchronous and top-level `await` did not exist. The
problem arrived with ECMAScript 2022, which introduced the feature. By then the
WHATWG Loader proposal had, in WebKit's words, effectively faded into obscurity
after being superseded by the ECMAScript standard's module section, but Safari's
loader was still based on it. So the feature was implemented in terms of a
proposal that predated any support for async/await in the language, rather than
in accordance with the standard's algorithms for asynchronous module execution.
WebKit attributes the resulting subtle bugs to that mismatch, and says they
resisted multiple attempts at a fix for years before the team decided to rebuild
the foundation instead of continuing to patch it.

The pull request that did it, WebKit/WebKit#57827, is titled "[JSC] Rewrite
module loader". It was opened on 2026-02-04 and merged on 2026-04-14, reviewed by
Yusuke Suzuki and Sosuke Suzuki. Its description is blunt about the starting
state: the loader had long-standing bugs, including assertion failures when run
on valid ES modules, incorrect ordering of module evaluation, and quirks with
top-level `await`. The patch removes the mixed C++/JS implementation and replaces
it with a pure C++ rewrite that follows the modern ECMAScript spec instead of the
old WHATWG Loader proposal. Concretely, `builtins/ModuleLoader.js` is deleted and
new native types (`CyclicModuleRecord`, `ModuleRegistryEntry`,
`ModuleGraphLoadingState`, `ModuleLoadingContext`) appear in its place, mirroring
the record and state machinery the specification itself describes.

### The bug you could reproduce in ten lines

WebKit's post centres on one reproduction: a `main.js` that dynamically imports
the same module three times concurrently and prints the keys of each resulting
namespace, where the imported module begins with a top-level `await` on a timer.
Under the old loader the imports completed in the order 2, 3, 1, and the first
two failed with `Cannot access 'someArray' before initialization`. Under the new
loader they complete 1, 2, 3 and every namespace is fully populated.

WebKit traces both symptoms to a single defect. When the loader first encountered
the module with the top-level `await`, it began executing it and paused at the
`await`, returning control to the importer, which started the second import. That
second import's promise should not have resolved until the first import had
finished evaluating, but in the old loader it resolved immediately. The second
import therefore "finished" while the module's evaluation was still suspended, so
reading its exports touched bindings that had not been initialised yet, and threw.
The same thing happened to the third import. Only the first one, which really had
completed evaluation by the time it settled, printed its keys successfully.

This is not a theoretical ordering nicety. The behaviour was filed as WebKit bug
242740, "[JSC] ReferenceError when multiple modules are simultaneously importing
a module containing a top-level await", on 2022-07-14, by a developer who noted
that one of the concurrent imports would be rejected, that sequential imports were
unaffected, and that neither Chrome nor Firefox rejected any of the promises on
the same reduction. That report sat open, now marked RESOLVED FIXED, for roughly
four years. The rewrite's pull request closes it.

### Why the rewrite went to native C++

The old loader was written in JavaScript as a self-hosted builtin, and WebKit is
candid that this had real advantages: builtins can be inlined into the user code
that invokes them, the cost of crossing the JavaScript/C++ boundary is avoided,
and object creation is faster from JavaScript because the heap allocation can
sometimes be eliminated outright.

The drawbacks it lists are the ones that won the argument. Self-hosted code is
slower to start because it must be compiled at run time, whereas native code is
compiled well in advance. Builtins have inherently wide usage characteristics,
which makes it harder for JavaScriptCore's optimising JIT compilers to exploit
patterns in how they are used. And the module loader is not a hot path, so the
upside of compiling it at runtime is small to begin with. The net effect WebKit
reports is performance that is less stable and predictable than C++ would give,
which is why the rewrite dropped the self-hosted approach entirely.

The method was deliberately unglamorous. WebKit says the work began in January
2026 by deleting the entire JavaScript file containing the old loader, then
implementing the specification's operations one at a time, translating its
pseudocode into C++. To sequence that work across what is a complex state
machine, the team read the spec, recorded how its functions called each other, and
assembled a flow graph; leaf functions such as `ExecuteModule` and
`ModuleRequestsEqual` were implemented first because they depended on nothing
else. A draft pull request went up after a few weeks, once the new loader handled
the most common cases.

### How WebKit convinced itself the rewrite was correct

Three independent sources of evidence, per the post. Engineers at Bun, whose
runtime is built on JavaScriptCore and therefore inherited the same loader
problems, supplied test cases they had collected that demonstrated incorrect
behaviour, which WebKit adapted to the `jsc` command line shell and landed as
tests. The team then wrote a fuzzer that generated complex graphs of modules (some
with top-level `await`, some without) and `import` statements, and compared
JavaScriptCore's output against other engines, treating a byte-for-byte match as
the pass condition; WebKit reports that every tested example was handled
correctly. Finally, the patch was held until all module-related test262 tests
passed and many previously failing WPT module tests were fixed with no
regressions. The pull request's own test additions name the specific failure
modes: `concurrent-imports.js` for evaluation order, `sync-from-async.js` for an
assertion failure that debug builds hit before the rewrite, `tla-cycle.js` for
cycles involving a top-level `await`, and `bare-resolution-failure.js` for caching
of resolution failures. WebKit adds that the team had been daily-driving a build
with the new loader for a few weeks before merge without issues.

## Why it matters for PWA developers

Top-level `await` is how a module performs asynchronous setup directly at module
scope. MDN describes the semantics plainly: you can use `await`
on its own outside an async function at the top level of a module, and modules
with child modules that use `await` will wait for those children to execute before
they themselves run, all while not blocking other child modules from loading.
WebKit's own framing of the module graph matches that: when a module hits a
top-level `await`, anything importing it is suspended until the `await` resolves,
while siblings that do not depend on it can still execute concurrently.

The condition the sources describe is narrow and easy to state: several importers
reaching for the same module that contains a top-level `await`, at the same time.
Bug 242740 reports that when modules were simultaneously fetching an import that
needed some setup time because of a top-level `await`, one of those imports would
be rejected, and that this did not happen when the modules were fetched
sequentially. WebKit's own reproduction is that situation written out: one module
importing the same top-level-`await` module three times concurrently.

What that cost developers is recorded in the bug itself. Its reporter noted that
the rejection did not occur in Chrome or Firefox on the attached reduction, which
places the behaviour in one engine rather than in the feature. A later commenter
on the same bug wrote that it was hard to do any workaround without a major
refactor and that it was not something you could polyfill; another pointed at an
issue in the SvelteKit repository where, they said, other developers had hit the
same bug. WebKit's wider
claim is worth noting too: with the loader rebuilt on the right foundation, it
positions ES modules as a whole as something you can build on in Safari without a
second thought. That is a statement about the loader's reliability in general, not
only about one operator.

The timing is no longer a caveat but a support-floor question. WebKit states that
Safari 27.0 comes automatically with macOS 27 Golden Gate, iOS 27, iPadOS 27 and
visionOS 27, and that it can also be installed on macOS 26 Tahoe and macOS 15
Sequoia separately from the OS. The fix is in a shipping release; what is left to
wait for is your own installed base reaching it.

WebKit's engineering post invited developers to download Safari Technology Preview 251
or the Safari 27 beta, try top-level `await`, and report anything that broke; 27.0 is
the released build on which to run that test. A site that avoided the defect by
importing a top-level-`await` module sequentially instead of concurrently, the one
difference bug 242740's reporter identified between the failing and passing cases,
can keep that arrangement until its own analytics show the pre-27 versions have
drained: a shipping fix moves the support floor, not the day's traffic.

## Related entries on this site

- [Platforms](/reference/platforms/): this is an engine-level behaviour change in
  one platform's JavaScript engine, and the platform pages are where OpenPWA
  tracks what each browser/OS combination can actually be relied on to do.
- [Baseline support overview](/reference/platforms/baseline-2026/): WebKit dates
  the arrival of the feature to ECMAScript 2022, and this page explains the
  Baseline tiers and the fixed set of browsers Baseline tracks, which is the frame
  you need for reading a fix that lands in one engine.
- [Compatibility by browser](/compatibility/by-browser/): the pivot to check
  before you rely on this: it shows per-browser-family support for PWA
  capabilities, which is the question you actually have once a fix ships in one
  engine.

## Open and unverified

- WebKit names the OS releases Safari 27.0 ships with and the two older macOS
  versions it can be installed on, but the loader rewrite is described at the
  release level only. The sources do not break the behaviour out per OS version or
  per device class, so whether every Safari 27.0 build on every Apple platform
  carries the rewritten loader is asserted for the release, not verified per
  platform here.
- WebKit's reliability claim for ES modules as a whole is a vendor
  characterisation of its own work. The test evidence cited (test262, WPT, the
  fuzzer, Bun's cases) is specific and checkable, but "build on it without a
  second thought" is not a measurement.
- The fuzzer's comparison was against other engines' output, with a byte-for-byte
  match as the pass condition. The sources do not enumerate which engines, nor
  how large the generated graphs were.
- Whether other JavaScriptCore embedders (Bun most obviously, given that it
  contributed test cases) inherit the rewrite, and on what schedule, is not
  addressed by the WebKit sources.

## Sources

- [WebKit blog, 2026-09-02: the module loader rewrite behind the top-level `await` fix, by Kai Tamkun](https://webkit.org/blog/18227/) (webkit.org)
- [WebKit Features for Safari 27.0, 2026-09-17: the shipping release that carries the new ES module loader](https://webkit.org/blog/18325/webkit-features-for-safari-27-0/) (webkit.org)
- [[JSC] Rewrite module loader, WebKit/WebKit#57827](https://github.com/WebKit/WebKit/pull/57827) (github.com)
- [WebKit bug 242740: ReferenceError on simultaneous imports of a module with a top-level await](https://bugs.webkit.org/show_bug.cgi?id=242740) (bugs.webkit.org)
- [Release Notes for Safari Technology Preview 251](https://webkit.org/blog/18194/release-notes-for-safari-technology-preview-251/) (webkit.org)
- [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) (developer.mozilla.org)