โ† All guides

TanStack Query useQuery, measured โ€” the storms that don't happen, the retry that hangs your error test, and the 500 that reports success

TanStack Query (React Query) is the most-used data-fetching library in React, and this series has been name-dropping it as "the cache layer you should reach for" since the first article. Time to measure it like everything else. The result is lopsided in an interesting way: the accidents that wreck raw useEffect, zustand, and useFetch mostly can't happen here โ€” and the surprises are all in the opposite direction: fetches you didn't ask for, and an error path whose defaults are tuned for production, not for your test suite.

Everything below was reproduced and measured in a real Chrome on @tanstack/react-query 5.103.2 + React 19.3.0 (Vite 5, client-side) with an instrumented window.fetch, render counters, and an abort counter. Two full runs produced identical numbers (retry timings ยฑ5 ms).

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=5"

That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CI โ€” anywhere. When you want your own schema it's one curl or one click โ€” see ยง15.

โšก 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 good path

const { data, status } = useQuery({
  queryKey: ['products'],
  queryFn: async () => {
    const r = await fetch(`${API}/products?limit=5`)
    if (!r.ok) throw new Error('HTTP ' + r.status)   // โ† load-bearing. See ยง11.
    return r.json()
  },
})

Measured: 1 fetch, 5 rows, 2 renders (pending โ†’ success), clean console. Nothing to report โ€” which, after eleven libraries, is itself a data point.

3. Three classic storms, zero storms

We ran the three accidents that generate the biggest numbers elsewhere in this series. TanStack Query shrugged off all three.

(a) new QueryClient() created inside a component โ€” with a parent re-rendering every 200 ms, so a fresh client (and a fresh, empty cache) exists on every render:

function App() {
  const [tick, setTick] = useState(0)          // re-renders 5ร—/second
  const qc = new QueryClient()                 // โ† recreated every render
  return <QueryClientProvider client={qc}><List/></QueryClientProvider>
}

Measured over 5 seconds: 1 fetch. Twenty-five renders created 24 clients that were silently thrown away โ€” useQuery latches onto the client present at first mount and ignores every replacement. So: no storm, but still a bug โ€” the discarded clients leak until GC, and any config on them (retry, staleTime) is never seen. Hoist the client to module scope or useState(() => new QueryClient()); measured behavior is identical (1 fetch) minus the garbage.

(b) An inline object in the queryKey โ€” the exact pattern that storms a useEffect dependency array (75 fetches in 4 s):

useQuery({
  queryKey: ['products', { page: 1, limit: 5, tags: ['a', 'b'] }],  // new identity every render
  queryFn: fetchProducts,
})

Measured with the same ticking parent: 1 fetch in 2 seconds across 11 renders. Query keys are hashed structurally, not by reference โ€” a new object with the same contents is the same key. This is the single biggest ergonomic difference from raw useEffect, and it's why the pager below needs no useMemo.

(c) React StrictMode โ€” mounts every component twice in dev: measured 1 fetch (4 renders). The first mount's request survives the throwaway unmount in the cache, and the remount subscribes to it in flight. No ignore flag, no double-fetch, unlike every hand-rolled effect in this series.

Two siblings using the same key dedupe the same way: 2 components, 1 fetch, both render 3 rows โ€” the request sharing VueUse's useFetch deliberately doesn't do.

4. The pager race is unloseable โ€” by architecture

The scenario that breaks naive implementations in Svelte, zustand, and Pinia: request page 1 (answers in 800 ms), then page 2 (answers in 100 ms) 150 ms later. Last-to-resolve wins and page-1 rows paint over a page-2 header. To reproduce it deterministically, the mock decides who's slow:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=1&_limit=3&mock_delay=800"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=3&mock_delay=100"

With queryKey: ['products', page], measured: 2 fetches, 0 aborts, header "page 2", rows are page 2's (first id 4) โ€” and when page 1's slow response lands 650 ms later, nothing on screen changes. There's no shared data variable to clobber: each response is filed under its own key, and the UI only reads the key it's currently subscribed to. The race isn't won with cleanup code โ€” it's structurally impossible to lose.

The stale request isn't even wasted by default (it warms page 1's cache). If you'd rather kill it, consume the signal TanStack Query hands every queryFn:

queryFn: ({ signal }) => fetch(url, { signal }).then(r => r.json())

Measured: same rows, same winner, but now exactly 1 abort โ€” the page-1 request is cancelled mid-flight the moment its last observer leaves.

5. What the pager shows during the flight: flash vs placeholder

Default behavior on a key change, measured with page 2 slowed to 600 ms and the DOM sampled 150 ms after the click: the rows are gone โ€” data is undefined for the new key, the list unmounts, and the user gets a loading flash on every page turn. One option restores the industry-standard behavior:

useQuery({
  queryKey: ['products', page],
  placeholderData: keepPreviousData,   // import { keepPreviousData } from '@tanstack/react-query'
  queryFn: โ€ฆ,
})

Same click, same sample point, measured: page 1's rows still on screen (first id 1) with isPlaceholderData: true alongside โ€” then the swap to id 4 when page 2 lands. Use the flag to dim the stale rows; the header already says "page 2" over page-1 data for the whole 600 ms, which is every keep-previous UI's honest caveat.

6. Every remount refetches: data is stale the moment it arrives

Default staleTime is 0. Unmount a loaded component, remount it 300 ms later, and sample the DOM 80 ms after the remount. Measured: 5 rows visible instantly (served from cache โ€” no loading flash) and isFetching: true and a second network request already in flight. 2 fetches total for one piece of data nothing changed.

This is the default people mean when they say "React Query hammers my API": every mount, every remount, every arriving observer triggers a background revalidation. It's also why your list "flickers" if row order isn't stable. If your data doesn't change mid-session, say so โ€” staleTime: 60_000 (or Infinity) and the remount is measured at 1 fetch, cache only.

7. gcTime: when the instant-cache render stops happening

Same experiment with gcTime: 1000 and the component left unmounted for 1.6 s: measured โ€” the remount shows the loading fallback again (0 rows, cache evicted while unobserved) and refetches from scratch. Default gcTime is 5 minutes; the two timers compose as: staleTime decides whether the cached copy triggers a refetch, gcTime decides whether there's a cached copy at all. If your "instant back-navigation" demo works in dev and cold-loads in prod, check which timer you tuned.

8. Tabbing back refetches too

refetchOnWindowFocus defaults to on. Measured: load the list (1 fetch), simulate the tab going hidden then visible again โ€” a second fetch fires immediately on the visibility flip. Combined with ยง6 this is the other half of the "hammering" reputation: check email, come back, that's a refetch; every remount, another. These are good defaults for dashboards over live data and noisy ones for static catalogs โ€” the point of measuring is knowing which knob you're turning off (refetchOnWindowFocus: false) and why.

Polling, for contrast, does exactly what it says: refetchInterval: 500 measured 6 fetches in 3.2 s, evenly spaced.

9. Skip the res.ok check and a 500 reports success

fetch() doesn't reject on HTTP errors, and useQuery only knows what your queryFn tells it. The one-liner every quickstart writes:

queryFn: () => fetch(url).then(r => r.json())   // โ† no res.ok check

Against a 500 (?mock_status=500), measured: status is "success", error is null, retry never runs โ€” and data is {"error":"simulated 500 error (mock_status)"}, your API's error page, cached as if it were products. The component that renders data.map(โ€ฆ) then throws data.map is not a function โ€” a render crash (blank page without an error boundary) that no onError callback will ever see, because as far as the cache is concerned this query succeeded. The if (!r.ok) throw line in ยง2 is what routes HTTP errors into the error machinery. Drill it against every branch:

curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500"   # any code: 401, 403, 429โ€ฆ

10. The default retry: your error test hangs for 7.1 seconds

Once errors do throw, the default client retries them. Measured against a permanent 500, network timestamps relative to the first request:

attemptfired atgap
10 msโ€”
21,046 ms~1 s
33,079 ms~2 s
47,116 ms~4 s

Sampled at the 2-second mark โ€” where a default test timeout is about to give up โ€” the query is still status: "pending", failureCount: 2, nothing rendered but the spinner. The error state finally lands at ~7.1 s after 4 attempts. If your error-path test "just times out", nothing is broken: three retries with exponential backoff are eating your assertion window. In tests, create the client with retry: false โ€” measured: 1 fetch, error state immediately. (Our companion testing guide covers the fresh-QueryClient-per-test pattern this implies.)

To test retry recovery โ€” not just retry failure โ€” the mock can serve a deterministic status script:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_seq=503,503,200&mock_seq_key=run1"

503, 503, then real data, with x-mockbird-seq: n/3 headers to assert each attempt. Use a fresh mock_seq_key per run.

11. Mutations: the invalidation round-trip, measured

const m = useMutation({
  mutationFn: (body) => post(`${API}/products`, body),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),
})

Measured: 1 POST, then invalidateQueries triggers exactly 1 list refetch, and the created row is on screen โ€” top of the list, because the query sorts ?_sort=id&_order=desc. The demo API is a real persistent store (POST โ†’ id 31 โ†’ visible in the refetch โ†’ we DELETE it in cleanup), which is what makes this assertable: mutation tests against a stub that discards writes can't fail when you forget the invalidation. Forget onSuccess here and the POST succeeds while the list stays stale โ€” the classic "it saved but doesn't show until refresh".

12. useSuspenseQuery: the fallback works, errors still need a boundary

Measured with the response slowed to 400 ms: <Suspense> fallback visible at 150 ms, rows on land, 1 fetch. Point it at a 500 (with retry: false): Suspense does not catch it โ€” the error throws past the fallback to the nearest error boundary, which renders Error: HTTP 500. No boundary = blank page. Same split every Suspense data source in this series shows (jotai measured identically): Suspense handles pending, boundaries handle rejected, and you need both.

13. The empty state you never see in dev

Seeded dev data means the "No products yet" branch ships untested โ€” and with useQuery it's a distinct state: status is "success" and data is [], not undefined. Pin a request to an empty snapshot โ€” live data untouched:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty"    # โ†’ []

Measured in the harness: success, zero rows, empty-state line renders, no error. data?.length === 0 is the honest check; !data conflates it with loading.

14. The drill kit

Behavior to testOne URL
Loading states, race choreography?mock_delay=800
Every error branch?mock_status=500 (or 401, 429โ€ฆ)
Retry recovery?mock_seq=503,503,200&mock_seq_key=run1
Empty state?mock_snapshot=empty
Flaky-network resilience?mock_chaos=0.3
Slow + jittery?mock_delay=500&mock_jitter=1000

15. 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 (mutations persist, as ยง11 relies on), filters, pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag above. Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.

16. Honest notes

Written by the Mockbird maker โ€” bias disclosed. TanStack Query measures like a library that has already met every footgun in this series: the storms don't ignite (ยง3), the race can't be lost (ยง4), StrictMode and sibling dedupe just work. Its surprises are all deliberate defaults pointed the other way โ€” refetch on mount, refetch on focus (ยง6โ€“ยง8), and a retry policy that makes untuned error tests hang 7 seconds (ยง10) โ€” plus the one genuine trap it shares with every fetch wrapper: res.ok is your job (ยง9). If you're choosing between this and a lighter tool, the question is whether you need the cache semantics; if you just need one component, one request, the smaller tools measured fine too. Measurements taken Sep 24, 2026 on @tanstack/react-query 5.103.2, React 19.3.0, Vite 5, client-side rendering; two runs, identical numbers (retry timings ยฑ5 ms). Related: testing TanStack Query (QueryClient/MSW gotchas), SWR, measured, React use() hook, createAsyncThunk, measured, RTK Query, Nuxt useAsyncData & useFetch, measured.

โ† All guides