Checkout refetches on every visit — even 3 seconds later
Force a refetch on mount only where correctness demands it, and keep the cached data so the screen still paints while it runs
What you'll build: a checkout page that always pulls fresh price/stock on enter without turning every other screen into a refetch storm — by using refetchOnMount: 'always' only where correctness demands it, and keeping gcTime so remounts still paint cached data while the forced fetch runs.
The scenario
An e-commerce SPA sets a global staleTime: 5 * 60_000 so product browsing feels instant. Users bounce list → detail → list within seconds and almost never wait on a spinner. Then checkout ships with the same defaults.
A shopper opens /checkout (stock check + price), backs to the cart for 3 seconds, returns to checkout. Under refetchOnMount: true, the 5-minute-fresh cache skips the network call. Two seconds earlier another buyer took the last unit; this shopper still sees "In stock" and submits → 409 / oversell. Support: "checkout showed available."
A panicked fix sets refetchOnMount: 'always' on the root QueryClient. Oversell stops. Now every list remount (tab switches, nested routes) fires background GETs even for catalogs that haven't changed in hours — Network tab lights up, mobile data spikes, INP dips on low-end phones.
Why it escaped QA: local stock never races another buyer; staleTime makes remounts look "correct" in single-session tests; the global 'always' "fix" is validated only on checkout, not on a 40-query dashboard.
Walkthrough
Stage 1 — Name it: fresh cache trusts staleTime; checkout can't
refetchOnMount: true vs 'always' diverge only when the entry is still fresh. Global staleTime: 5m means "trust this cache for five minutes after a successful fetch." That is right for a product grid. It is wrong for price, stock, wallet, coupon at the moment of commitment — those need a mount-time fetch even if the browser thinks the data is fresh.
Stage 2 — Reject the two wrong fixes
Global 'always'. Correctness for one route becomes cost for every route. You wanted a per-query override.
staleTime: 0 everywhere. Every focus and remount refetches (staleness lifecycle) — same storm, different dial. Don't punish the whole app for checkout's needs.
Stage 3 — Scope 'always' (and keep gcTime)
Keep the global browsing defaults. Override on the commitment queries:
// features/checkout/hooks/useCheckoutQuote.tsimport { useQuery } from "@tanstack/react-query";import { checkoutKeys } from "../api/checkoutKeys";import { fetchCheckoutQuote } from "../api/checkoutApi";
export function useCheckoutQuote(cartId: string) { return useQuery({ queryKey: checkoutKeys.quote(cartId), queryFn: ({ signal }) => fetchCheckoutQuote(cartId, signal), staleTime: 30_000, // still useful for *within*-checkout soft nav refetchOnMount: "always", // even if fresh — stock/price at enter gcTime: 5 * 60_000, // keep preview data across a brief leave });}Remount within gcTime: UI paints the previous quote immediately; 'always' starts a background fetch; when it lands, price/stock update. Remount after GC: first-load spinner — correct, there is nothing safe to show.
Lists stay on defaults (refetchOnMount: true, multi-minute staleTime).
Stage 4 — Harden + verify the loop
- Pair with mutation invalidation after cart changes (
invalidateQueriesoncheckoutKeys.quote) so you don't rely on remount alone. - Optional:
refetchOnWindowFocus: trueon the quote so a tab left open during a flash sale still converges.
Verify the loop. Seed a quote, wait 3s (still fresh under 5m global), remount checkout: Network shows a quote GET. Remount a product list within staleTime: no list GET. Drop stock on the server between remounts: UI updates after the background fetch; submit no longer uses the stale "In stock" claim.
Variations
- Wallet / gift-card balance — same
'always'(orstaleTime: 0) on the balance query only. staleTime: 0on the quote — equivalent "always eligible";'always'is clearer when a parent default sets a longstaleTime.- Loader prefetch — route loader
prefetchQuerythe quote at nav time; component still uses'always'so a client remount without nav still refreshes. - WebSocket stock — push into
setQueryData;'always'becomes backup, not the only freshness path. - SSR hydrate then
'always'— warm first paint fromHydrationBoundary; remounts still force a client check.
Trade-offs and common pitfalls
- Global
'always'— correctness leak becomes a refetch storm. - Assuming
'always'disablesgcTime— it doesn't; GC still drops preview data. - Spinner on every remount with
'always'— you setgcTime: 0or gated the UI onisFetchinginstead ofisPending. - Forgetting invalidate after cart edits — remount-only freshness misses same-page mutations.
- Putting
'always'on infinite lists — burns quota; page withtrue+ sensiblestaleTime. - Confusing
refetchOnMountwithrefetchOnWindowFocus— different triggers; tune separately. - Checkout in a keep-alive tab — no remount → no mount refetch; add focus refetch or polling.
- Shared
queryOptionswithout the override — list and checkout accidentally share the soft options. - Treating 409 as a UI bug — server must still enforce stock; client freshness only reduces races.
- QA only on cold load — the bug is warm remount inside
staleTime. - Mobile data surprise — measure Network after the global
'always'"fix."
When NOT to use 'always'
If the worst case of slightly stale data is a refresh away and not a money/stock/auth decision — product descriptions, blog posts, settings copy — keep refetchOnMount: true and a real staleTime. 'always' is for commitment surfaces, not every GET.
See also
- Refetch triggers and dials — the full trigger table.
refetchOnMounttrue vs always — mechanism this recipe applies.- Dashboard never updates while watching — idle/stale without a trigger (polling).
- Request waterfall — don't confuse remount refetch with serial waterfalls.
References
- TanStack Query — Important Defaults
- TanStack Query — Window Focus Refetching
Demo source
demos/data-fetching/refetch-on-mount-always-spams/— warm remount skips fetch undertrue, forces under'always', list unaffected. (Demo host TBD)