← All guides

Mock a REST API for Qwik β€” fetching with tasks, signals and stores, without the traps

Qwik's data primitives behave differently enough from React's that the bugs are different too. The classic fetch-in-render storm? Qwik forgives it β€” we measured exactly one fetch. But a useTask$ that tracks the signal it writes refetches forever with zero warnings, sig.value.push() freezes the DOM while store.items.push() renders fine, and task re-runs queue behind your in-flight fetch β€” a fresh search query waited 2.6 seconds behind a stale one until useResource$'s built-in cleanup aborted it in 320 ms.

This guide pairs a client-rendered Vite + Qwik app with a hosted mock API and walks through each behavior β€” every one reproduced and measured in a real Chrome on Qwik 1.20 before publishing. All numbers below (~75 silent refetches in 4s, 3 rows vs 4 items, 2.6s vs 320ms, exactly 1 AbortError) are observed counts from an instrumented window.fetch, 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, or anywhere else β€” no proxy config. When you want your own schema it's one curl or one click β€” see Β§10.

⚑ 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 silent useTask$ refetch loop: ~75 requests in 4 seconds, zero warnings

"Retry while the list is empty" and "refresh whenever the data changes" both tempt you into a task that tracks the signal it writes:

import { component$, useSignal, useTask$ } from '@builder.io/qwik';

export const ProductList = component$(() => {
  const items = useSignal([]);
  useTask$(async ({ track }) => {
    track(() => items.value);            // reads items…
    const r = await fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3');
    items.value = await r.json();        // …and writes items β†’ task re-runs β†’ fetch β†’ …
  });
  return <p>{items.value.length} items</p>;
});

Measured: mounted once in a real Chrome, this fired 76 fetches in the first 4 seconds (a second run counted 34 at 2s, 73 at 4s, 93 at 5s β€” still climbing). It never stops, and β€” unlike Svelte 5's effect_update_depth_exceeded or Preact Signals' Cycle detected β€” Qwik logged zero warnings and zero errors while doing it. Each response assigns a new array, the tracked signal changes, the task re-runs. Against a real backend that's a self-inflicted DDoS; against the demo API it's a burned daily quota.

The fix: track your inputs (the query signal, the page number), never the signal you're filling. For one-shot loads, use useVisibleTask$ β€” measured: exactly 1 fetch β€” or better, useResource$.

3. The React storm Qwik forgives (measured: exactly 1 fetch)

In React or Preact, calling fetch + setState directly in the component body is the canonical infinite loop β€” we measured 76 renders/76 requests in 4s in the Preact guide. The same mistake in Qwik:

export const Naive = component$(() => {
  const items = useSignal([]);
  // the classic React mistake, transplanted:
  fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
    .then(r => r.json())
    .then(d => { items.value = d; });
  return <p>{items.value.length} items</p>;
});

Measured: exactly 1 fetch, 1 component-body run, and the DOM still updated to "3 items" (both runs). Qwik's fine-grained reactivity updates the text binding without re-running the component function, so the loop never closes. Don't ship it anyway: the body is supposed to be side-effect-free, it refetches on every remount, and in a Qwik City app with SSR that code runs on the server per request. But if you're porting React code and this bug rode along, Qwik quietly saved you β€” worth knowing when traffic graphs differ between ports.

4. push() renders in stores, freezes in signals

Two container primitives, two reactivity models, opposite behavior on the same line of code:

const sig = useSignal([]);
const store = useStore({ items: [] });

sig.value.push(newItem);      // BUG: same array reference β†’ signal never fires
store.items.push(newItem);    // fine: stores are deep proxies β†’ renders

sig.value = [...sig.value, newItem];  // signal fix: new reference

Measured: after loading 3 products into each, sig.value.push() grew the array to length 4 while the DOM stayed at 3 <li>s β€” no warning, no render. The same push() on store.items rendered 4 rows immediately. And here's the nasty part: when we later assigned the signal a new array via spread, the previously-pushed ghost item appeared too (5 rows) β€” it had been sitting in the array all along, waiting for any re-render to expose it. State and DOM disagreeing silently, then "healing" later, is the worst kind of bug to bisect.

Rule: mutate useStore freely (deep proxy tracks it); treat useSignal values as immutable and reassign.

5. useResource$: the fetch primitive Qwik actually ships

Instead of hand-rolled loading flags, useResource$ gives you pending/resolved/rejected states, automatic re-runs when tracked signals change, and a cleanup() hook that's the natural home for an AbortController:

import { component$, useSignal, useResource$, Resource } from '@builder.io/qwik';

export const ProductSearch = component$(() => {
  const q = useSignal('');
  const res = useResource$(async ({ track, cleanup }) => {
    const query = track(() => q.value);        // re-runs when q changes
    const ac = new AbortController();
    cleanup(() => ac.abort());                 // aborts the previous in-flight run
    const r = await fetch(
      `https://mockbird.mockbird.workers.dev/m/demo/products?q=${encodeURIComponent(query)}&limit=20`,
      { signal: ac.signal }
    );
    if (!r.ok) throw new Error('HTTP ' + r.status);
    return r.json();
  });
  return (
    <>
      <input bind:value={q} />
      <Resource
        value={res}
        onPending={() => <p>loading…</p>}
        onResolved={(items) => <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>}
        onRejected={(e) => <p>error: {e.message}</p>}
      />
    </>
  );
});

Typing in the input is all it takes β€” the resource re-ran on its own in our harness, and the cleanup() line is what wins the race in Β§7.

6. All four UI states, measured

The demo API can simulate every state your <Resource> branches need, straight from the URL:

StateURLWhat we measured
loading?limit=5&mock_delay=2000onPending renders for the full delay, then resolved with 5 items
data?limit=5onResolved, 5 items
error?limit=5&mock_status=500onRejected branch with HTTP 500
empty?category=nonexistent-catonResolved with [] β€” style your empty state, it's not the error state
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_status=500"
curl -w "%{time_total}s\n" -o /dev/null -s \
  "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_delay=2000"

All four verified in the harness: loading β†’ data:5, error:HTTP 500, empty. mock_delay and mock_status work on any endpoint β€” no separate fixtures.

7. The stale-search race β€” Qwik queues it; useResource$ cancels it

Type "life", results are slow; type "way", results are instant. In React/Preact/Lit the naive version shows the wrong results β€” the slow response lands last and overwrites. Qwik's failure mode is different, and we measured it:

Measured (naive tracked useTask$, no abort): we issued a slow broad query (q=life + 2.5s simulated latency) then a fast one (q=way) 300ms later. Qwik serializes task re-runs: the fresh query's fetch didn't even start until ~2.6 seconds in β€” after the stale response fully resolved β€” and the stale "life" results flashed on screen for ~50ms before being replaced. Final result correct, zero aborts, but the user stared at a spinner for 2.6s waiting on a query they'd already abandoned.

Measured (same sequence through useResource$ + cleanup): changing q mid-flight aborted the first run (exactly 1 AbortError observed), the fresh fetch started at ~320ms β€” immediately on keystroke β€” and resolved ~50ms later. The stale query never rendered at all.

?mock_delay=2500 is how you make this reproducible instead of "sometimes flaky on hotel wifi": pin the latency on the first request, race it deliberately, assert the right result renders.

8. Load-more pagination with X-Total-Count

const loadMore = $(async () => {
  page.value++;
  const r = await fetch(`.../m/demo/products?page=${page.value}&limit=10`);
  total.value = Number(r.headers.get('X-Total-Count'));   // CORS-exposed
  items.value = [...items.value, ...await r.json()];      // new reference (see Β§4)
});

Measured: three clicks loaded 10 β†’ 20 β†’ 30 of 30, the button's disabled binding flipped to true at the boundary, and the rendered list held exactly 30 rows with 0 duplicate ids. X-Total-Count is exposed via CORS so r.headers.get() works cross-origin β€” many real APIs forget that and the header silently reads null.

9. Deterministic retry tests with ?mock_seq

Retry logic tested against a healthy API is untested. mock_seq serves an exact status script β€” fail, fail, succeed:

curl -s -o /dev/null -w "%{http_code}\n" \
  "https://mockbird.mockbird.workers.dev/m/demo/products?limit=2&mock_seq=503,503,200&mock_seq_key=me1"

Measured: three requests observed exactly [503, 503, 200] β€” deterministic, not "randomly fails sometimes" like chaos flags. On the shared demo the counter is scoped per client IP (and mock_seq_key isolates parallel workers), so the link above starts fresh for you too. Statuses β‰₯400 are simulated before processing, so a failed write is never half-applied β€” safe to point real retry logic at.

10. 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.

11. Beyond this guide

12. Honest comparison

ToolGood atWhere this differs
MSW (Mock Service Worker)In-process request interception for unit tests; no network at allMSW mocks live inside each test runner. Mockbird is a real hosted URL β€” the same mock serves your browser, your CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared.
Qwik City's routeLoader$Server-side data loading in full Qwik City apps β€” the right default for SSR pagesrouteLoader$ still needs an API to load from in dev, preview deploys, and client-side interactions after resume. This guide's URLs work as that backend from both the server and the browser (open CORS), no proxy config.
json-serverLocal full-featured fake REST; huge ecosystemNeeds Node running on every machine that wants the API. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted β€” and adds auth simulation, failure injection, snapshots.

Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements β€” rerun them in your own Chrome with the snippets as written. (Harness: Vite + Qwik 1.20 in CSR mode; one note if you replicate it β€” CSR without Qwik City needs the qwikloader script imported explicitly or event handlers silently never fire.)