← All guides

Mock a REST API for Svelte 5 β€” $effect fetching without the traps

Svelte 5's runes changed how client-side data fetching goes wrong. The old onMount-and-forget patterns still work, but the moment you move fetching into $effect β€” which is what the reactivity model nudges you toward β€” a new family of bugs appears: effects that rerun themselves into effect_update_depth_exceeded, $derived values that render as [object Promise], and search boxes that show results for a query you typed three keystrokes ago.

This guide pairs a plain client-side Svelte 5 app (Vite, no SvelteKit) with a hosted mock API and walks through each trap β€” every one reproduced and measured in a real Chrome on Svelte 5.57 before publishing, including the loop that fired 1,001 network requests from one innocent-looking line. If you're on SvelteKit with load functions and SSR, that's a different set of gotchas: see the SvelteKit guide.

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, or anywhere else β€” no proxy config. When you want your own schema it's one curl or one click β€” see Β§9.

⚑ 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 $effect infinite loop, measured: one innocent counter β†’ 1,001 requests

The rule everyone learns the hard way: an effect that synchronously reads a piece of state it also writes will rerun itself forever. Nobody writes count += 1 in an effect on purpose β€” but everybody instruments a fetch:

<script>
  // THE BUG: an innocent-looking run counter inside $effect.
  // `attempts += 1` READS attempts and WRITES it back synchronously,
  // so the effect depends on state it changes -> reruns itself forever.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let attempts = $state(0);
  let products = $state([]);
  $effect(() => {
    attempts += 1;                       // <- the loop
    fetch(`${API}/products?limit=1`)
      .then((r) => r.json())
      .then((d) => (products = d));
  });
</script>

<p>{attempts} attempts</p>
<p>{products.length} products</p>

What we measured when this mounted, in a real Chrome with a fetch counter installed: the effect ran 1,000 times and launched 1,001 fetch calls in under two seconds, then Svelte killed the page with:

effect_update_depth_exceeded
Maximum update depth exceeded. This typically indicates that an effect
reads and writes the same piece of state
https://svelte.dev/e/effect_update_depth_exceeded

Against a real backend that's a self-inflicted DDoS β€” and possibly a rate-limit ban. Against a mock it's a cheap lesson. Two fixes, depending on what you meant:

import { untrack } from 'svelte';

$effect(() => {
  untrack(() => (attempts += 1));      // write without subscribing
  fetch(`${API}/products?limit=1`)
    .then((r) => r.json())
    .then((d) => (products = d));
});

Verified: with untrack() the same component settled at 1 attempt, 1 fetch, 1 product rendered. (The other fix is structural: if the counter is only for debugging, use a plain let outside runes β€” non-reactive variables can't retrigger anything.)

2a. Why your .then() write does not loop (and what that implies)

Here's the part that confuses people who've internalised the rule above. This effect both reads and writes items β€” and runs exactly once per page change:

<script>
  // Looks like it should loop (reads items, writes items) -- but doesn't:
  // the read happens inside .then(), AFTER the effect's synchronous run,
  // so Svelte never registers `items` as a dependency.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let page = $state(1);
  let items = $state([]);
  $effect(() => {
    void page;                            // sync read: page IS a dependency
    fetch(`${API}/products?_page=${page}&_limit=10`)
      .then((r) => r.json())
      .then((batch) => (items = [...items, ...batch]));  // async read: NOT tracked
  });
</script>

<p>page {page}</p>
<p>{items.length} items</p>
<button onclick={() => (page += 1)}>Load more</button>

Measured: mount β†’ effect ran once, 10 items. Click "Load more" β†’ effect ran exactly twice total, 20 items. No loop, ever. $effect only tracks reads that happen during its synchronous execution. Anything after an await or inside a .then() is invisible to the dependency tracker. That's why the counter in Β§2 loops (sync read) and this append doesn't (async read) β€” and it's also the trap's mirror image: if you expected this effect to rerun when items changes elsewhere, it won't.

3. Fetch-on-mount with all four UI states β€” each one reachable on demand

The standard list component has four states: loading, error, empty, data. The mock lets you flip between them with a query param instead of hunting for a backend that happens to be broken:

<script>
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  // Flip branches from the URL so every UI state is reachable on demand:
  //   ?sim=mock_status=500     -> error branch
  //   ?sim=category=nope       -> empty branch
  //   ?sim=mock_delay=3000     -> watch the loading branch
  const sim = new URLSearchParams(location.search).get('sim') || '';
  let status = $state('loading');   // 'loading' | 'error' | 'empty' | 'ready'
  let products = $state([]);
  let error = $state('');

  $effect(() => {
    load();
  });

  async function load() {
    status = 'loading';
    try {
      const res = await fetch(`${API}/products?limit=12${sim ? '&' + sim : ''}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      products = await res.json();
      status = products.length ? 'ready' : 'empty';
    } catch (e) {
      error = e.message;
      status = 'error';
    }
  }
</script>

{#if status === 'loading'}
  <p>Loading…</p>
{:else if status === 'error'}
  <p>Couldn't load products ({error}). <button onclick={load}>Retry</button></p>
{:else if status === 'empty'}
  <p>No products match.</p>
{:else}
  <ul>
    {#each products as p (p.id)}<li>{p.name} β€” ${p.price}</li>{/each}
  </ul>
{/if}

Verified: plain load renders 12 products; ?sim=mock_status=500 lands in the error branch with "HTTP 500" and a working Retry; ?sim=category=nope lands in the empty branch; ?sim=mock_delay=3000 holds "Loading…" (sampled mid-flight at 1.2 s) before settling. Those three simulation params β€” mock_status, mock_delay, and real server-side filters β€” are the whole point of a mock you can command.

Why $effect and not onMount? For a one-shot load they behave the same, and onMount is still fine. The moment the request depends on reactive state (a search box, a route param, a filter), $effect reruns automatically where onMount silently goes stale β€” and Β§5's cleanup pattern only exists in $effect. Note the sync/async subtlety from Β§2a still applies: load() here works because $effect doesn't need to track anything except mount.

4. The $derived trap: your template renders [object Promise]

$derived is synchronous. Derive from an async call and you get the Promise itself β€” and Svelte will happily render it:

<script>
  // THE TRAP: $derived is synchronous. Deriving from an async function
  // gives you the Promise itself, and the template happily renders it.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let id = $state(1);
  let product = $derived(
    fetch(`${API}/products/${id}`).then((r) => r.json())
  );
</script>

<p>{product}</p>

Verified output on screen: [object Promise]. No warning, no error β€” it just looks broken. The idiomatic client-side fix is to derive the promise on purpose and let {#await} unwrap it:

<script>
  // Deriving the PROMISE is fine -- as long as the template awaits it.
  // When `id` changes, $derived makes a new promise and {#await} resets
  // to the pending branch automatically.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let id = $state(1);
  let productPromise = $derived(
    fetch(`${API}/products/${id}?mock_delay=600`).then((r) => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    })
  );
</script>

<button onclick={() => (id += 1)}>Next product</button>
{#await productPromise}
  <p>Loading…</p>
{:then product}
  <p>{product.name} β€” ${product.price}</p>
{:catch e}
  <p>failed: {e.message}</p>
{/await}

Verified: pending branch on mount, product renders, clicking "Next product" flips back to "Loading…" and then renders a different product β€” the reassigned promise resets the {#await} block automatically. The ?mock_delay=600 is there so you can actually see the pending branch; drop it in real code.

5. The stale-search race β€” and $effect teardown as the abort home

A search box that refetches per keystroke has a race: a slow older response can land after a fast newer one and clobber it. With mock_delay you can force the interleaving instead of hoping to reproduce it:

<script>
  // THE BUG: no cancellation. A slow older response can land AFTER a
  // fast newer one and clobber it.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let query = $state('');
  let results = $state([]);
  let shownFor = $state('');

  $effect(() => {
    const q = query;                 // sync read -> dependency
    const delay = q === 'sta' ? 3000 : 300;   // harness: make the stale one slow
    fetch(`${API}/products?q=${encodeURIComponent(q)}&mock_delay=${delay}`)
      .then((r) => r.json())
      .then((d) => { results = d; shownFor = q; });
  });
</script>

<input bind:value={query} placeholder="search products" />
<p>results for: "{shownFor}"</p>
<p>{results.length} results</p>

Measured: type sta (its request delayed 3 s), keep typing to stable (each later request 300 ms) β€” the page ends up showing results for: "sta". The stale response won. The fix is the most underused feature of $effect: return a teardown function and Svelte runs it right before every rerun β€” the exact moment the in-flight request became stale:

$effect(() => {
  const q = query;
  const delay = q === 'sta' ? 3000 : 300;
  const controller = new AbortController();
  fetch(`${API}/products?q=${encodeURIComponent(q)}&mock_delay=${delay}`,
    { signal: controller.signal })
    .then((r) => r.json())
    .then((d) => { results = d; shownFor = q; })
    .catch((e) => { if (e.name !== 'AbortError') throw e; });  // aborts are not errors
  return () => controller.abort();   // teardown: cancel the stale request
});

Measured with the identical typing sequence: the page ends up showing results for: "stable". Two things to keep: the teardown also fires on unmount (no leaked requests when the user navigates away), and an aborted fetch rejects with name === 'AbortError' β€” swallow it, don't render it as an error banner.

6. Retry logic against deterministic failures

?mock_seq=503,503,200 makes the API fail the first two requests and serve the real list on the third β€” no flaky randomness, so you can assert the exact attempt count:

<script>
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let attempts = $state(0);
  let products = $state([]);
  let status = $state('loading');

  async function fetchWithRetry(url, tries = 3) {
    for (let i = 1; i <= tries; i++) {
      attempts = i;
      const res = await fetch(url);
      if (res.ok) return res.json();
      if (i === tries) throw new Error(`HTTP ${res.status} after ${tries} attempts`);
      await new Promise((r) => setTimeout(r, 250 * 2 ** (i - 1)));  // 250ms, 500ms…
    }
  }

  $effect(() => {
    fetchWithRetry(`${API}/products?limit=5&mock_seq=503,503,200&mock_seq_key=sv5`)
      .then((d) => { products = d; status = 'ready'; })
      .catch(() => (status = 'error'));
  });
</script>

<p>{status}</p>
<p>{attempts} attempts</p>
<p>{products.length} products</p>

Verified: exactly 3 attempts, then "ready" with 5 products. On the shared demo the sequence counter is scoped per client IP, so the link works fresh for every visitor; change mock_seq_key to restart your own counter (details in the docs). Note attempts = i is safe here precisely because of Β§2a: it happens after an await, untracked.

7. Real pagination with X-Total-Count

<script>
  // Real pagination against X-Total-Count -- the header tells you when to
  // stop rendering the Next button.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  const PER_PAGE = 12;
  let page = $state(1);
  let products = $state([]);
  let total = $state(0);
  let totalPages = $derived(Math.max(1, Math.ceil(total / PER_PAGE)));

  $effect(() => {
    const p = page;
    fetch(`${API}/products?page=${p}&limit=${PER_PAGE}`).then(async (res) => {
      total = Number(res.headers.get('X-Total-Count'));
      products = await res.json();
    });
  });
</script>

<p>page {page} of {totalPages} β€” {products.length} shown, {total} total</p>
<button disabled={page <= 1} onclick={() => (page -= 1)}>Prev</button>
<button disabled={page >= totalPages} onclick={() => (page += 1)}>Next</button>

Verified: "page 1 of 3 β€” 12 shown, 30 total", Next-Next lands on "page 3 of 3 β€” 6 shown" with Next disabled. X-Total-Count is in Access-Control-Expose-Headers, so the browser can actually read it β€” the detail most real APIs forget, and the reason this pattern mysteriously returns NaN against half the backends you'll meet. This is also a clean $derived use: totalPages from total, synchronous, no trap.

8. A mock JWT login flow

The demo issues real signed JWTs for any email/password, and /auth/me reads the token back β€” enough to build the whole auth UI before an auth backend exists:

<script>
  // Real signed JWTs from the mock: log in with ANY email/password,
  // send the token, read the user back from /auth/me.
  const API = 'https://mockbird.mockbird.workers.dev/m/demo';
  let token = $state(localStorage.getItem('token') || '');
  let me = $state(null);
  let err = $state('');

  async function login() {
    const res = await fetch(`${API}/auth/login`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: 'dev@example.com', password: 'anything' }),
    });
    const data = await res.json();
    token = data.token;
    localStorage.setItem('token', token);
  }

  $effect(() => {
    if (!token) { me = null; return; }
    fetch(`${API}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
      .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
      .then((d) => (me = d.user ?? d))
      .catch((e) => (err = e.message));
  });
</script>

{#if me}
  <p>signed in as {me.email}</p>
{:else}
  <button onclick={login}>Log in</button>
{/if}
{#if err}<p>{err}</p>{/if}

Verified: click Log in β†’ "signed in as dev@example.com". Because token is $state read synchronously in the effect, setting it after login automatically triggers the /auth/me fetch β€” the reactive graph doing the plumbing. Token expiry, register-that-persists, and protecting the whole API behind the token are in the mock JWT auth guide.

9. Get your own API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "Content-Type: application/json" \
  -d '{"preset": "ecommerce"}'

The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments β€” or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is an env-var change: VITE_API_URL=https://api.yourcompany.com.

10. Beyond this guide

11. Honest comparison

Hardcoded fixturesMSWLocal json-serverMockbird
Setup in a Vite + Svelte appedit the JSnpm dep + service worker file + handlersNode process on every machinea URL
Exercises real HTTP / CORS / headers / timingβœ—βœ— β€” intercepts in-pagepartly (localhost only)βœ”
Reproduces Β§2's request storm consequencesβœ—βœ— (storms are free in-process)βœ”βœ” β€” and survives it
Latency / error / sequence injectionβœ—βœ” in handler codemiddleware to writeone query param
Same URL works in StackBlitz, CI, a teammate's machineβœ” (still fake)βœ” (still fake)βœ—βœ”
Offlineβœ”βœ”βœ”βœ— β€” real network call

Be clear-eyed: MSW is excellent for unit tests and Storybook, where in-process interception is a feature. A hosted mock is the opposite trade β€” everything crosses a real wire (real CORS preflights, real exposed headers, real timing) except the backend's existence. Use both: MSW in vitest, a URL for the browser you're actually clicking around in.

Related: Svelte 5 $effect fetch patterns, measured (the async-keyword freeze, the loseable race, the storm the depth guard can't see), mock API for SvelteKit (load functions, streamed skeletons, form actions), mock API for Solid (the closest cousin to runes-style reactivity), mock API for TanStack Query (if you'd rather a library owned Β§5 and Β§6), and testing loading and error states.

Verification: every snippet on this page was run verbatim as components of a real Vite + Svelte 5.57.1 app in a real Chrome against production on 22 Sep 2026, with results asserted programmatically: the 1,000-rerun / 1,001-fetch loop and its exact effect_update_depth_exceeded page error, the untrack() fix settling at 1 attempt / 1 fetch, the Β§2a append running exactly once then twice (10 β†’ 20 items, no loop), all four Β§3 branches including "Loading…" sampled mid-flight, [object Promise] on screen in Β§4 and the {#await} reset on id change, the stale-search race lost ("sta") then won ("stable") with teardown-abort, the 3-attempt mock_seq recovery, 12-of-30 β†’ 3 pages with Next disabled on the last, and the JWT login β†’ /auth/me round trip. If a snippet doesn't work in your app, that's a bug: tell us.