Skip to content

Safari 27 makes top-level await spec-compliant

Published

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.

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.

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.

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

Section titled “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.

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.

  • 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 — 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 — 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.
  • Run your own top-level await code against Safari 27.0. WebKit’s invitation, in the engineering post, was to download Safari Technology Preview 251 or the Safari 27 beta, try integrating top-level await, and send feedback if anything broke; 27.0 is now the released build to do that on.
  • Treat Safari 27.0 as the support floor for the fixed behaviour: WebKit states the rewritten loader is in that release, and says nothing of the kind about earlier ones.
  • Where you avoided the defect by importing a module with a top-level await sequentially instead of concurrently — the one difference bug 242740’s reporter identified between the failing and passing case — keep that in production until your own analytics show the pre-27 versions have drained. A shipping fix moves your support floor, not today’s traffic.
  • If you still see wrong evaluation order or an “accessed before initialization” error, file it. WebKit points bug reports at bugs.webkit.org, and bug 242740 is the precedent for a user report driving this work.
  • 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.