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.
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.
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$.
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.
push() renders in stores, freezes in signalsTwo 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.
useResource$: the fetch primitive Qwik actually shipsInstead 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.
The demo API can simulate every state your <Resource> branches need, straight from the URL:
| State | URL | What we measured |
|---|---|---|
| loading | ?limit=5&mock_delay=2000 | onPending renders for the full delay, then resolved with 5 items |
| data | ?limit=5 | onResolved, 5 items |
| error | ?limit=5&mock_status=500 | onRejected branch with HTTP 500 |
| empty | ?category=nonexistent-cat | onResolved 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.
useResource$ cancels itType "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.
X-Total-Countconst 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.
?mock_seqRetry 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.
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.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) β build optimistic updates and verify against reality.POST /m/<project>/auth/login returns a real signed JWT for any email+password β test login flows and 401 handling without a backend (guide).GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod schemas) β typed useResource$<Product[]> for free.beforeEach β deterministic Playwright/Cypress runs (guide).| Tool | Good at | Where this differs |
|---|---|---|
| MSW (Mock Service Worker) | In-process request interception for unit tests; no network at all | MSW 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 pages | routeLoader$ 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-server | Local full-featured fake REST; huge ecosystem | Needs 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.)