← All guides

Svelte 5 $effect fetch patterns, measured β€” the async keyword that freezes your pager, the race you can lose, and the storm the depth guard can't see

Svelte 5's runes made reactivity explicit: $state holds it, $derived computes it, $effect reacts to it. But unlike TanStack Query, SWR, or even Solid's createResource, $effect has no opinion about data fetching at all β€” no dependency tracking through async, no stale-response handling, no error channel. Every protection you get for free elsewhere has to be earned by hand here, and when you miss one, the failure is silent: a pager that froze the moment you typed async, page-2 data rendered under a page-3 header, an invisible hundred-fetch storm that Svelte's own infinite-loop guard is structurally unable to catch.

Everything below was reproduced and measured in a real Chrome on svelte 5.57.1 (Vite + @sveltejs/vite-plugin-svelte, client-side) with an instrumented window.fetch before publishing. Fetch counts, abort counts, and on-screen text are observed values from two identical instrumented runs, not estimates.

1. Try it in 10 seconds (no signup)

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 Β§12.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The baseline that works

The natural Svelte 5 fetch-on-state-change pattern β€” read the state synchronously, write in a .then:

let page = $state(1)
let items = $state([])

$effect(() => {
  fetch(`${BASE}/products?_page=${page}&_limit=5`)
    .then(r => r.json())
    .then(j => { items = j })
})

Measured: clicking 1 β†’ 2 β†’ 3 produced exactly 3 fetches, and the list followed each time. $effect tracks every piece of state read synchronously during its run, so page is a dependency and the effect re-runs on change. This works β€” keep the shape in mind, because Β§3 breaks it with one keyword.

3. The async keyword that freezes your pager

Now the refactor everyone eventually types β€” you want await inside the effect, so you mark it async:

$effect(async () => {
  await somethingFirst()
  const r = await fetch(`${BASE}/products?_page=${page}&_limit=5`)
  items = await r.json()
})

Measured: the initial load works (1 fetch, page-1 list renders). Click page 2: the header β€” which reads {page} in the template β€” says page 2. The list stays page 1. Fetch count stays 1. Click page 3: header page 3, list still page 1, still one fetch ever. Zero warnings, zero errors, nothing in the console.

$effect only tracks reads that happen synchronously, before the first await. Here page is first read after an await, so the effect registers no dependencies at all and never re-runs. The template read keeps the header live, which makes the bug look impossible β€” the same variable is "reactive" three lines up. If you've read our Vue watchEffect or Solid createResource measurements, this is the same genus of trap β€” but Svelte's version is armed by a single keyword. The fix: read your dependencies before any await (or keep the effect sync and call an async function, as in Β§2).

4. The race you can lose: page-2 data under a page-3 header

Back to the working Β§2 pattern. What happens when responses arrive out of order? We made page 2 slow (mock_delay=2000 β€” one query param on the mock) and page 3 fast, clicking them 300 ms apart:

# page 2's fetch gets ?mock_delay=2000 β€” the mock holds it for 2s
# page 3's fetch is instant

Measured: at 900 ms everything looks right β€” header page 3, page-3 items rendered (3 fetches, 0 aborts). Then the slow page-2 response lands at ~2.3 s and overwrites the list: page-2 items under a header that still says page 3. A raw $effect fetch is last-to-resolve-wins, not last-requested-wins. Solid's createResource and Svelte's own {#await} (Β§8) both discard stale responses automatically; a hand-rolled $effect fetch does not.

5. The teardown that fixes it (and gives you aborts for free)

$effect has exactly the right hook for this: return a cleanup function and it runs before every re-run and on destroy. That's an AbortController's natural habitat:

$effect(() => {
  const ctrl = new AbortController()
  fetch(`${BASE}/products?_page=${page}&_limit=5`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(j => { items = j })
    .catch(e => { if (e.name !== 'AbortError') throw e })
  return () => ctrl.abort()
})

Measured (same slow-p2/fast-p3 drill): exactly 1 abort, page-3 list correct at 900 ms β€” and still correct after the moment the doomed page-2 response would have landed. The stale request died on the wire instead of clobbering your UI. This is the single highest-value line in this guide: if you fetch in $effect, return an abort.

6. {#each} over undefined β€” the crash that doesn't happen

What if the state starts undefined and you iterate it without a guard?

let items = $state()   // undefined until the fetch resolves

<ul>{#each items as it}<li>{it.name}</li>{/each}</ul>

Measured (fetch held 800 ms so the window is observable): during the undefined window the page renders empty β€” no crash, zero console output β€” and hydrates to 5 items when the response lands. Svelte's {#each} tolerates null/undefined. Genuinely nice: React's blind data.map crashes the component and Solid's blind data().map blanks the whole app even inside Suspense. Svelte is the only framework in our measured series where the naive version of this is safe.

7. The 500 that renders as data

fetch doesn't reject on HTTP errors, and nothing in $effect checks for you:

// simulate: one query param on the mock makes it return HTTP 500
fetch(`${BASE}/products?mock_status=500`)
  .then(r => r.json())          // ← no res.ok check
  .then(j => { items = j })

Measured: the effect "succeeds" β€” items becomes {"error":"simulated 500 error (mock_status)"}, the error body rendered as if it were data, list empty, zero console output. Add if (!r.ok) throw new Error('HTTP ' + r.status) β€” and then read Β§9, because where that throw goes depends on your template.

8. {#await}: the free unloseable race β€” and the list it throws away

Svelte's built-in answer is to put the promise in the template. Derive it from state and {#await} it:

let page = $state(1)
let promise = $derived(load(page))   // async function returning r.json()

{#await promise}
  <p>loading…</p>
{:then items}
  <ul>{#each items as it}<li>{it.name}</li>{/each}</ul>
{:catch e}
  <p>error: {e.message}</p>
{/await}

Measured, the good part: the Β§4 race is unloseable here. Slow page 2 then fast page 3: page-3 items render, and when the stale page-2 response finally lands it is ignored β€” {#await} only honors the promise it's currently awaiting. The race protection you had to hand-build in Β§5 is free.

Measured, the cost: click page 2 (1.5 s response) from a rendered page-1 list, and at 400 ms the list is gone β€” 0 items on screen, replaced by the pending branch β€” even though perfectly good data was just there. Every promise change re-enters pending; there is no built-in keep-previous-data. (Note the stale response is discarded but not aborted β€” the bytes still arrive; combine with Β§5 if that matters.) If you want the old list to stay while the new page loads, keep data in $state and manage it with Β§5's effect instead β€” or accept the flash.

9. The missing {:catch} β€” the region that goes silently blank

Same {#await}, loader throws on !r.ok (mock returns 500), but you didn't write a {:catch} branch:

{#await promise}
  <p>loading…</p>
{:then items}
  <ul>…</ul>
{/await}

Measured: the pending branch disappears, the then branch never renders, and the region is simply blank β€” no error UI, no crash, nothing. The only trace anywhere is an unhandled promise rejection in the console (window.onerror never sees it; you need an unhandledrejection listener to even log it). With {:catch e} the same failure renders your error branch immediately (boom: HTTP 500 in our run, zero uncaught anything). One branch is the difference between a debuggable failure and a support ticket that says "the page is just empty".

10. The storm the depth guard can't see

Svelte 5 famously protects you from infinite effect loops. Write the synchronous version and it does:

$effect(() => { n = n + 1 })
// Uncaught Svelte error: effect_update_depth_exceeded
// "Maximum update depth exceeded…"  (measured: thrown at n = 1000)

Now the async version β€” an effect that reads the state its own fetch writes:

$effect(() => {
  void items                        // read β†’ dependency
  fetch(`${BASE}/products?_page=1&_limit=5`)
    .then(r => r.json())
    .then(j => { items = j })      // write β†’ re-trigger
})

Measured: 37–43 fetches in 2 seconds, 95–109 in 5 seconds β€” forever, rate-limited only by your network. Zero console output. No guard fires. The UI looks completely normal (5 items, no flicker). effect_update_depth_exceeded only counts synchronous re-runs; a write that happens in a .then continuation resets the clock every time, so the loop is invisible to Svelte and to you β€” until the bandwidth bill or the rate limiter finds it. The same self-retrigger shape measured 106 fetches/5s in Solid and similar in Vue: no framework's loop guard catches the async variant. Check your network tab, and don't make effects depend on state their own callbacks write.

11. Drill retry logic without breaking anything real

Retry code paths are exactly the ones you can't test against a healthy backend. The mock can fail deterministically β€” mock_seq returns a scripted sequence of statuses per key:

const key = 'sv' + Math.random().toString(36).slice(2, 8)
// 503, 503, then 200 β€” in that order, per key
const url = `${BASE}/products?_page=1&_limit=5&mock_seq=503,503,200&mock_seq_key=${key}`

Measured with a plain retry loop (5 attempts max, 250 ms backoff): exactly 3 fetches, then a rendered list, no errors. Your backoff code just proved it retries the right number of times and recovers β€” deterministically, in CI, every run. Also available: mock_delay (Β§4's slow page), mock_status (Β§7's 500), chaos injection for probabilistic failures.

12. Your own backend in one curl

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.

13. Honest notes

Written by the Mockbird maker β€” bias disclosed. Svelte 5 comes out of these measurements with the best defaults-when-you-know-them in our series: {#each} over undefined is the only naive-iteration that doesn't crash (Β§6), the teardown-return is a genuinely elegant abort hook (Β§5), and {#await} gives you an unloseable race for free (Β§8). The traps are the silent ones: one async keyword disarming dependency tracking with zero warnings (Β§3 β€” the docs do note effects "re-run when state read synchronously changes", but nothing warns you at the keyboard), a loseable race in the pattern everyone writes first, and a fetch storm that the loop guard is structurally blind to (Β§10). SvelteKit apps should reach for load functions and remote functions before any of this β€” this guide is about the client-side $effect/{#await} patterns that component code actually uses. Measurements taken Sep 24, 2026 on svelte 5.57.1, Vite 5, client-side rendering, two identical instrumented runs. Sequel: Svelte await expressions, measured β€” the experimental async model fixes most of these traps (and the one it keeps, it warns about). Related: mocking for Svelte and for SvelteKit (states, retries, pagination recipes), Vue watchEffect, measured, Solid createResource, measured, React 19 use(), measured.

← All guides