Since 5.36, Svelte lets you write await directly in components β top of the <script>, inside $derived(...), and in markup β behind an experimental.async flag that will be removed in Svelte 6 (i.e. this becomes the normal way to fetch in Svelte). We previously measured what hand-rolled $effect fetches do wrong: a pager frozen by one async keyword with zero warnings, a race you can lose, data clobbered by stale responses. So the obvious question: which of those bugs does the async model actually fix?
Almost all of them β and the one that survives now warns. Everything below was reproduced and measured in a real Chrome on svelte 5.57.1 with experimental.async: true and an instrumented window.fetch. Fetch counts, console output, and on-screen text are observed values from two identical instrumented runs, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, the Svelte playground, CI β anywhere. When you want your own schema it's one curl or one click β see Β§13.
// svelte.config.js β or the svelte() plugin options in vite.config.js
export default {
compilerOptions: {
experimental: { async: true }
}
}
Requires svelte β₯ 5.36 (we measured on 5.57.1). Without the flag, await in these positions is a compile error. The flag disappears in Svelte 6 β the semantics below are the future default, which is exactly why they're worth measuring now.
$derived behind a boundary<!-- Products.svelte -->
<script>
let page = $state(1)
async function load(p) {
const res = await fetch(`${BASE}/products?_page=${p}&_limit=5`)
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
}
let items = $derived(await load(page))
</script>
<h2>page {page}</h2>
<ul>{#each items as it}<li>{it.id}: {it.name}</li>{/each}</ul>
<!-- App.svelte -->
<svelte:boundary>
<Products />
{#snippet pending()}<p>loadingβ¦</p>{/snippet}
{#snippet failed(error, reset)}β¦{/snippet}
</svelte:boundary>
Measured: the pending snippet flashes, then 5 rows render. Clicking page 1 β 2 β 3 produced exactly 3 fetches, list correct each time, zero errors. Note the shape: page is passed as an argument into load(p) β the state read happens synchronously in the $derived expression. That detail is load-bearing; Β§5 measures what happens when you move it.
Every framework we've measured shows the same tell during an in-flight page change: the header (which reads state directly) flips immediately, while the list (which waits on the network) lags β page-1 rows under a page-2 header. Angular's resource() does it, $effect does it, naive Vue does it. Svelte's async model claims synchronized updates: state changes aren't reflected in the UI until the async work they trigger has completed. We held every fetch for 800 ms (mock_delay=800 β one query param on the mock) and probed mid-flight:
Measured: 400 ms after clicking page 2 β with the second fetch already on the wire β the header still says page 1 and the list still shows page-1 rows. At ~800 ms, header and list flip together. The inconsistent frame that every other measured framework renders simply never exists. No code on your side: no transition API, no keep-previous-data flag, no coordination. This is the single biggest thing the async model buys you, and it's invisible unless you probe for it.
In the $effect guide, reading state after an await silently froze the pager: one fetch ever, zero warnings. Here's the async-model version of the same mistake β the state read moved inside the loader, after an earlier await:
async function load() {
await somethingFirst()
const p = page // β state read AFTER an await
const res = await fetch(`${BASE}/products?_page=${p}&_limit=5`)
return res.json()
}
let items = $derived(await load())
Measured: the bug still exists β clicking page 2 and 3 updates the header, but fetch count stays 1 and the list stays frozen on page 1. But unlike $effect, unlike Angular resource(), unlike Solid's untracked fetcher β Svelte now tells you. The console fires await_reactivity_loss:
[svelte] await_reactivity_loss
Detected reactivity loss when reading `page`. This happens when state
is read in an async function after an earlier `await`
https://svelte.dev/e/await_reactivity_loss
β¦with a stack trace pointing at the read. Of the five frameworks in our measured series with this trap, this is the only one that detects it. The fix is the same as ever: read state synchronously (pass it as an argument, as in Β§3) β but now the framework catches you when you forget.
The $effect version of this drill ends badly: slow page-2 response lands last and clobbers page-3 data. Same drill here β page 2 slow (mock_delay=2000), page 3 fast, clicked 300 ms apart, against the Β§3 baseline:
Measured: 3 fetches, 0 aborts. At 900 ms: header page 3, page-3 rows β the fast update overtook the slow one mid-flight, exactly as the docs promise ("updates can overlap"). At 2.6 s, after the slow page-2 response has landed: still page 3, still page-3 rows. The stale response was discarded, not rendered β without an AbortController, a teardown, or any code at all. (The bytes still arrive β 0 aborts means the request wasn't cancelled at the network layer; if bandwidth matters, that's still your job.) The highest-value line in the $effect guide β "return an abort from every fetch effect" β is simply not needed for correctness here.
The docs say independent markup awaits run in parallel. Two awaits, each held 800 ms:
<p>{await one()}</p>
<p>{await two()}</p>
Measured: fetch starts 4 ms apart, both paragraphs visible at ~900 ms. Parallel, confirmed. Now the same two calls as sequential script deriveds:
let a = $derived(await one(x)) // 600ms each
let b = $derived(await two(y))
Measured: on first creation the starts are 677 ms apart β a real waterfall, both visible at ~1.3 s β and Svelte fires the exact warning:
[svelte] await_waterfall
An async derived, `a` (src/WaterfallIn.svelte:15:10) was not read
immediately after it resolved. This often indicates an unnecessary
waterfall, which can slow down your app
Then the docs' subtle claim β "once created they will update independently" β which we've not seen measured anywhere: click a button that updates x and y simultaneously. Measured: the two update fetches start 1 ms apart. The waterfall exists only on creation; updates are parallel. If the creation waterfall matters, move the awaits into markup or a single derived.
$effect.pending() covers the restMeasured: the boundary's pending snippet renders during the first load only. On a page change it is not re-shown β the old rows stay on screen (no blanking, consistent with Β§4) β and {#if $effect.pending()} is what turns on mid-flight: our "updatingβ¦" badge appeared during the fetch and disappeared after. This split is the same keep-old-rows behavior Angular's reloading status gives you, but here it falls out of the model instead of being a status enum you check. One badge, one line:
{#if $effect.pending()}<span class="badge">updatingβ¦</span>{/if}
failed snippet, reset() β and the state it throws awayThrow on !res.ok and the error lands in the boundary's failed snippet with a reset function. We scripted the backend to fail once then recover β mock_seq=500,200 returns those statuses in order for a given key:
{#snippet failed(error, reset)}
<p>{error.message}</p> <!-- rendered: HTTP 500 -->
<button onclick={reset}>retry</button>
{/snippet}
Measured: first load fails β HTTP 500 renders, zero rows. Click retry β exactly 2 fetches total, 5 rows, failed snippet gone. Deterministic, CI-safe, no real backend harmed.
The gotcha we measured the hard way: reset() re-creates the boundary contents. Instance state β everything in the component's normal <script> β is rebuilt from scratch. Our first harness derived its mock_seq key with Date.now() in instance scope; every reset minted a fresh key, restarted the failure sequence, and retried into a fresh 500, forever. Moving the key to <script module> (module scope survives the remount) fixed it. If your retry path depends on any state accumulated before the failure β attempt counters, cursors, request ids β it won't survive reset() unless it lives outside the boundary.
fetch still doesn't reject on HTTP errors, and the async model doesn't check res.ok for you either. Skip the check with the mock returning mock_status=500:
const res = await fetch(`${BASE}/products?mock_status=500`)
return res.json() // β no res.ok check; body is {"error": "simulated 500 error (mock_status)"}
Measured: items becomes the error-body object, and {#each items} over an object renders zero rows, zero warnings, zero errors β the failed snippet never triggers. Each framework in this series serves this dish differently: Pinia rendered the error body as one garbage row, Angular resource() reported status: 'resolved' with the error as data. Svelte's version is an empty list with a completely clean console β arguably the hardest of the three to debug. The one-line fix is the same everywhere: if (!res.ok) throw new Error('HTTP ' + res.status) β which Β§9 then turns into real error UI.
Measured: on the client β no. The Β§3 component mounted with no <svelte:boundary> anywhere works: 1 fetch, 5 rows, zero errors (nothing renders until the data resolves β there's just no placeholder UI and nowhere for errors to land). The boundary requirement you'll see in the docs is about SSR (await outside a boundary with a pending snippet blocks render() until resolved) and about having pending/failed UI at all. For quick client-side spikes, $derived(await β¦) alone is fine; for anything real you want the boundary for Β§8's placeholder and Β§9's error channel.
await settled(): knowing when the dust has clearedtick() resolves when pending state changes are applied β but with async work in the graph that's not the same as "the UI is done". settled() (new in 5.36) resolves when state changes and the async work they triggered have landed in the DOM:
import { settled } from 'svelte'
page = 2 // triggers a 500ms fetch
await settled()
// DOM now shows page-2 rows
Measured: first row before = 1: House Bridge Night Child Time; first row immediately after await settled() = 6: Way City Right Life (page 2's first item). For component tests this is the assertion gate you previously faked with setTimeout.
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews β full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker β bias disclosed. This is the most favorable measurement session in our framework series: synchronized updates (Β§4) and automatic stale-response discard (Β§6) eliminate the two ugliest hand-rolled-fetch bugs outright, and the one trap that survives β reactivity loss after an await β is the only version of that trap in any framework we've measured that warns (Β§5). Caveats, honestly: this is experimental β the flag and details like $effect.pending() can change outside semver until Svelte 6; discarded responses are not aborted (bandwidth is still yours to manage, Β§6); reset() rebuilding instance state will surprise you exactly once (Β§9); and the silent-500 family is alive and well, now rendering as a cleanly empty list (Β§10). SvelteKit apps should still fetch in load functions first β this guide is about component-level data flow, where these semantics are about to become the Svelte default. Measurements taken Sep 24, 2026 on svelte 5.57.1 (experimental.async: true), Vite 5, client-side rendering, two identical instrumented runs. Related: Svelte 5 $effect fetch patterns, measured (the before-picture), mocking for Svelte, for SvelteKit, Angular resource(), measured, Solid createResource, measured.