# Safari 27 Fixed top-level await — Retested, and Loading Doubled

> Reran WebKit's top-level await repro on Safari 26.6 and iOS 26.5. Past the ReferenceError, modules declared after a TLA one wait for it — loading doubled.

**Published:** 2026-09-03 | **Updated:** 2026-09-03

---


Safari's top-level await bug was reported back in July 2022 ([WebKit bug 242740](https://bugs.webkit.org/show_bug.cgi?id=242740)), and by October 2025 it had gotten bad enough that top-level await dropped to "Limited" on Baseline (the cross-browser support signal for web platform features). The fix merged in April 2026, shipped starting with Safari Technology Preview 243 in May, and on September 2 the WebKit team posted the postmortem: [Fixing top-level await in Safari](https://webkit.org/blog/18227/fixing-top-level-await-in-safari/).

Two things I wanted to know: what exactly was broken, and does my own Safari still have the problem. I ported the blog's reproduction code to run in a browser and tried it on Safari 26.6, the iOS 26.5 simulator, and Chrome. It reproduced — and turned up one symptom bigger than anything the blog post showed.

## top-level await (TLA) in 30 Seconds

**top-level await** (TLA from here on) is ES2022 syntax that lets you write `await` directly at the top level of an ES module, with no `async` function wrapping it. Anything that imports that module waits for the `await` to finish before running its own body.

```js
// config.mjs — the export isn't ready until the config fetch resolves
export const config = await fetch('/config.json').then((r) => r.json())
```

```js
// app.mjs — runs only after config.mjs's await has settled
import { config } from './config.mjs'
console.log(config.apiBase)
```

Convenient, but it gives the engine one more job: figuring out exactly who in the module graph needs to wait for whom. That's precisely where Safari was getting it wrong.

## What Was Broken — Import the Same Module Three Times

WebKit's reproduction is simple. One module waits 10ms, and the main script calls it three times through dynamic `import()` — the form that pulls a module in mid-execution, like calling a function.

```js
// test-module.mjs
await new Promise((resolve) => setTimeout(resolve, 10))

export function someFunction() {
  return 'Hello!'
}
export const someArray = []
```

```js
// main.mjs — runs as <script type="module">. The blog's `print` is a jsc shell built-in; swapped for console.log here
const print = (...args) => console.log(...args)

async function load(index) {
  try {
    print('Importing', index)
    const module = await import('./test-module.mjs')
    print('Imported', index)
    try {
      print(`Keys for ${index}:`, Object.keys(module))   // touch each export
    } catch (e) {
      print('Accessing', index, 'failed:', e.message)
    }
  } catch (e) {
    print('Importing', index, 'failed:', e.message)
  }
}

await Promise.all([1, 2, 3].map(load))
```

The expected completion order is 1, 2, 3 (that's what the blog shows too), and `someArray` should be accessible right away from whichever import gets there. But this article's actual test isn't ordering — it's **whether accessing an export before initialization throws at all**. Completion order alone flips around even in Chrome on repeated runs. Here's what pre-fix Safari produces:

```text
Importing 1
Importing 2
Importing 3
Imported 2
Accessing 2 failed: Cannot access 'someArray' before initialization.
Imported 3
Accessing 3 failed: Cannot access 'someArray' before initialization.
Imported 1
Keys for 1: someArray,someFunction
```

While the first import is mid-evaluation (running the module body to fill in its exports) and paused at `await`, the second and third imports' promises **resolve before that evaluation is even done.** At that point `export const someArray` isn't initialized yet, so `Object.keys` throws a ReferenceError on the spot. In the blog's own words: "The promise for the second import shouldn't resolve until after the first import is done evaluating, but due to a bug in the old module loader, it resolves immediately."

{{< img src="images/contents/tla-import-order.png" alt="Timeline comparison for importing the same module three times - per spec, Chrome, and Safari 27, all three imports finish in order 1, 2, 3 after evaluation completes, but on Safari 26 and earlier the second and third imports complete mid-evaluation, ending up 2, 3, 1 and throwing a pre-initialization access error" >}}

## Testing It on Safari 26.6 and iOS 26.5

I turned the code above into a [demo page](https://isaaceryn.github.io/demo_codes/safari-top-level-await/?lang=en). I also added a second experiment: an entry point statically imports (the ordinary way, an `import` statement at the top of the file) three mutually unrelated modules in this order — `tla-a` (a 300ms TLA), `sibling` (synchronous), `tla-b` (another 300ms TLA). I timed when each module started and finished using `performance.mark`, the browser's built-in timeline-recording API.

| Environment | Pre-init access failures | Completion order | Entry point runtime with two static TLA imports |
|---|---|---|---|
| Chrome 148 / 152 | 0 | Mostly 1, 2, 3 (occasionally flips on repeat) | 302ms — parallel |
| Safari 26.6 (macOS) | 2 | 2, 3, 1 | 602ms — serial |
| iOS 26.5 simulator Safari | 2 | 2, 3, 1 | 603ms — serial |

{{< img class="phone" src="images/contents/ios-safari-26-tla-bug.png" alt="Demo page opened in Safari on the iOS 26.5 simulator - the run log shows Imported 2, Accessing 2 failed: Cannot access 'someArray' before initialization, Imported 3, the same failure, then Imported 1, Keys for 1, and the verdict below reads 2 pre-initialization access failures, matching WebKit's own bug output exactly" caption="Live demo opened on the iOS 26.5 simulator. The red verdict line marks 2 failures." >}}

The first experiment matched the blog word for word: both Safari 26.6 and iOS 26.5 threw 2 pre-initialization access errors, Chrome threw 0. That much was just confirmation. The second experiment is the actual reason for this post.

## The Symptom the Blog Didn't Show — Later Modules Wait on an Earlier TLA

`tla-a` and `tla-b` know nothing about each other. Per spec, a single pass through the entry point's import list should start all three, both TLAs should wait their 300ms side by side, and the entry point should run at roughly the 300ms mark. Chrome landed at 302ms. But pre-fix Safari didn't start `sibling` or `tla-b` until `tla-a`'s `await` had finished — both also landed around 302ms after `tla-a` — and the entry point didn't run until 602ms. Whatever was declared after the TLA module had to wait for the whole thing.

I reran it under a few different conditions. Declare `sibling` before the TLA modules, and it evaluates at 0ms even on Safari. Stretch the TLA wait to 1000ms, and the delay on the later modules stretches to match. A diamond graph — two modules both importing the same TLA module — behaved fine. So it's not a download-speed thing. It's evaluating things **strictly in the order their `import` statements appear.** Think of it like laundry: two washers running side by side versus one washer doing two loads back to back.

With multiple TLA modules, spec behavior costs you only as long as the slowest one. Pre-fix Safari costs you the sum of all of them. Two independent TLA modules — say, a config fetch and a wasm init — and you're paying double, just like that.

That this counts as a spec violation isn't my own reading of it. The WebKit post opens by saying "sibling modules in the dependency graph that don't depend on the awaiting module can still execute concurrently," and the [tc39 proposal document](https://github.com/tc39/proposal-top-level-await#why-doesnt-top-level-await-block-the-import-of-an-adjacent-module) nails it down with an X1/Y/X2 example: "importing one module 'before' another does not create an implicit dependency." The blog's own reproduction only covers the early-resolve bug, but the [PR that rewrote the loader (#57827)](https://github.com/WebKit/WebKit/pull/57827) lists "incorrect ordering of module evaluation" among the bugs it fixes — which reads like a second face of the same underlying defect.

> **Note**: the reversed-order, 1000ms, and diamond-graph results are measurements from my own setup (Safari 26.6, iOS 26.5, Chrome). Chrome throttles timers in background tabs, which can inflate numbers, so these are all from a foregrounded tab.

## Why This Happened — 2021 Syntax Bolted Onto an Abandoned Proposal

Safari's module loader was built against the WHATWG Loader proposal, last updated in January 2016 — a proposal that was later superseded and folded into the ECMAScript spec's own modules section. When Safari shipped TLA in 2021, it landed on top of that old loader, which put it out of step with the async module evaluation algorithm ES2022 actually defines. So starting in January 2026, WebKit stripped out the engine's internal JavaScript implementation and rewrote the loader function-by-function in C++, following the spec's pseudocode directly. They report passing every module test in test262 (JavaScript's official conformance suite), fixing a batch of previously-failing WPT (cross-browser test suite) module tests, and fuzzing random module graphs against other engines' output to compare results.

## Can You Trust It Now

**From Safari 27 on, yes.** The fix has been in Safari Technology Preview 243 (May 2026) and the Safari 27 beta all along, and the blog post itself says you can rely on TLA in production once 27 ships.

The problem is everything before that. On iOS, the OS update and Safari version are locked together, and — outside the EU, where Chrome and Firefox on iOS are required to run their own engines — Chrome and Firefox on iOS still run on system WebKit, so switching browsers doesn't get you around it. Three guardrails seem worth keeping while older versions are still in the picture:

1. **Keep TLA out of shared chunks and library code — finish evaluating static imports at your entry point before your first dynamic import.** The bug reproduces when a TLA module that's still mid-evaluation gets dynamically imported more than once concurrently. Code shared across pages usually ends up in one shared chunk from your bundler, and if that chunk gets imported at a time offset — through route transitions or preloading — you can hit the same condition without ever writing `import()` twice yourself. Bug 242740 has reports of SvelteKit, Astro, Stencil, and the ArcGIS SDK running into exactly this, and in my own diamond-graph test, a static import graph behaved fine even on the old loader.
2. **Export an `init()` function if you need to control initialization timing.** This is the exact pattern the TLA proposal argued against — for hurting static analysis — brought back temporarily because of a browser bug. It won't help if a third-party library is the one using TLA, though.
3. **Check how your bundler emits TLA.** webpack compiles TLA into its own runtime code, so no native TLA ever reaches the browser — effectively unaffected. Vite, esbuild, and Rollup's ESM output leave TLA as-is. Searching your shipped bundle for a top-level `await` outside any function tells you which camp you're in.

```js
// Guideline 2 — caching the promise means concurrent callers only trigger one fetch
let pending = null

export function init() {
  // ??= only assigns the right side when the left side is empty
  return (pending ??= fetch('/config.json').then((r) => r.json()))
}
```

## One-Page Summary

- **TLA**: `await` directly at the top level of an ES module. Anything importing it waits for that to finish before running its own body (ES2022).
- **What was broken**: dynamically importing the same in-progress TLA module more than once made the second call's promise resolve before evaluation finished, throwing `ReferenceError: Cannot access … before initialization`. Reproduced exactly on Safari 26.6 and iOS 26.5.
- **The symptom the blog didn't show**: pre-fix Safari serializes every module declared after a TLA module. Two TLAs: 302ms on Chrome, 602ms on Safari.
- **Root cause**: a loader built on the 2016 WHATWG Loader proposal, out of step with ES2022's async module algorithm. Rewritten in C++ against the spec's pseudocode starting January 2026, merged in April.
- **Right now**: spec-correct from STP 243 and the Safari 27 beta onward. While you still support older versions, keep TLA out of shared chunks, and check your bundle output unless you're on webpack.

{{< faq >}}

## Wrapping Up

It's not every day a vendor writes up the postmortem on a four-year-old bug themselves. This time, it really was the browser's fault. Just — don't say that in front of a device running Safari 27. From there on, it's your code.

**References**

- [Fixing top-level await in Safari — WebKit Blog (2026-09-02)](https://webkit.org/blog/18227/fixing-top-level-await-in-safari/)
- [WebKit bug 242740 (reported 2022-07)](https://bugs.webkit.org/show_bug.cgi?id=242740)
- [WebKit PR #57827 — loader rewrite (merged 2026-04)](https://github.com/WebKit/WebKit/pull/57827)
- [web-features #2957 — Baseline reconsideration issue](https://github.com/web-platform-dx/web-features/issues/2957)
- [tc39 proposal-top-level-await — adjacent module section](https://github.com/tc39/proposal-top-level-await#why-doesnt-top-level-await-block-the-import-of-an-adjacent-module)
- [Safari top-level await reproduction demo (this article's test page)](https://isaaceryn.github.io/demo_codes/safari-top-level-await/?lang=en)
</content>

