Stale-While-Revalidate Data Fetching with SWR and TanStack Query
This guide brings the pattern from Stale-While-Revalidate Implementation into the browser's data layer, within Advanced Caching Strategies & CDN Architecture. HTTP stale-while-revalidate lets caches serve a stale response while refreshing it in the background. Client data libraries — SWR and TanStack Query for React (with ports for Vue, Svelte and Solid) — apply the same idea to application data: when a component needs /api/products?cat=shoes, the library returns the cached result immediately if it has one, renders, and refetches in the background, updating the UI if the data changed.
The performance effect is largest on route transitions in single-page apps. Without client caching, navigating back to a list re-fetches and shows a loading state; with SWR semantics, the list appears instantly from cache and quietly refreshes. The risks are in configuration: refetch-on-focus and refetch-on-mount defaults can multiply API traffic, and a cache that is never considered fresh refetches on every render.
Rapid Diagnosis
- Watch the Network panel during navigation. If returning to a previously visited view refetches and shows a spinner, there is no client cache (or it is configured as always-stale with loading states).
- Count requests per view. Duplicate requests for the same key from several components indicate missing deduplication.
- Check refetch triggers. Switching tabs and returning may trigger refetches of every active query (
refetchOnWindowFocus). - Look at loading states. UIs that show spinners for data the app fetched seconds ago are not using cached data during revalidation.
Root Cause Analysis
1. Fetching in effects without a cache. useEffect(() => fetch(...)) patterns refetch on every mount and cannot share results between components.
2. Zero staleTime everywhere. TanStack Query's default staleTime is 0, meaning cached data is immediately stale; it is still shown instantly, but refetches happen on every mount and focus, multiplying API calls.
3. Loading UI on background refetch. Rendering spinners whenever isFetching is true discards the benefit of having cached data.
4. No prefetching. The first visit to each view always waits; prefetching on hover makes even that instant.
Step-by-Step Resolution
1. Move data fetching into a query library
import { useQuery } from '@tanstack/react-query';
function ProductList({ category }) {
const { data, isPending } = useQuery({
queryKey: ['products', category],
queryFn: () => fetch(`/api/products?cat=${category}`).then((r) => r.json()),
staleTime: 60_000, // fresh for a minute: no refetch on remount
});
if (isPending) return <ListSkeleton />;
return <Grid items={data} />;
}
// trade-off: a 60s staleTime means a list can be up to a minute out of date on
// return. For volatile data (stock, prices), use a shorter value or invalidate
// on the mutations that change it.
Expected outcome: revisiting the view renders instantly from cache; refetches happen at most once a minute.
2. Configure global defaults deliberately
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, gcTime: 10 * 60_000, refetchOnWindowFocus: false, retry: 1 } },
});
// trade-off: disabling focus refetching saves requests but leaves data stale
// after long absences. Enable it per query for data that matters (inbox, cart).
3. Show cached data during background refetch
Render from data whenever it exists, and show a subtle indicator for isFetching rather than a full loading state.
4. Prefetch on intent
<Link to={`/c/${slug}`} onMouseEnter={() => queryClient.prefetchQuery({
queryKey: ['products', slug], queryFn: () => fetchProducts(slug), staleTime: 60_000 })}>
{name}
</Link>
// trade-off: hover prefetching on dense menus can fire many requests; add a
// short hover delay, as with route chunk prefetching.
Expected outcome: first visits to likely next views are also instant.
Verification
Navigate between two views several times with the Network panel open: the second and later visits should render instantly and issue at most one background request per stale query. Count API requests per session in RUM or server logs before and after; they should fall even with prefetching enabled. Measure route transition time with User Timing; cached views should transition in a single frame.
Worked Example: A Dashboard SPA
An analytics dashboard fetched widget data in effects; switching between three dashboards re-fetched 40 widgets each time, with loading spinners on every switch and API p95 latency spiking under load. Migrating to TanStack Query with staleTime: 120_000, focus refetching only for alert widgets, and prefetching on dashboard-tab hover made switches instant after the first visit and cut API traffic by 64%. INP for tab switches improved too, because rendering from cache avoided two state updates (loading, then loaded) per widget.
Server Rendering and Hydrating the Cache
In SSR frameworks, fetch data on the server and pass it to the client cache (TanStack Query's dehydrate/HydrationBoundary, SWR's fallback). The client then starts with fresh data and does not refetch immediately on hydration — avoiding the double fetch that commonly doubles API load in SSR apps. Set staleTime above zero so hydration does not trigger an immediate background refetch of everything the server just fetched.
Common Mistakes
- Unstable query keys. Keys built from objects with changing identity cause cache misses; use serialisable, stable keys.
- Loading states on refetch. Users see spinners for data they already have.
- Forgetting invalidation after mutations. Call
invalidateQueriesfor affected keys so stale data does not linger after the user changes it. - Caching personal data across users. Clear the query cache on logout.
Edge Cases
Pagination. Use placeholderData: keepPreviousData so moving between pages does not blank the list.
Infinite queries. Refetching an infinite list refetches every page; cap pages or refetch only the first.
Offline. Persist the query cache to IndexedDB for offline-capable apps, with versioning so schema changes do not crash old caches.
Request deduplication. Both libraries dedupe concurrent requests for the same key; make sure components share keys rather than inventing variants.
FAQ
Is SWR (the library) the same as stale-while-revalidate (the header)?
They share the strategy but operate at different layers. The header instructs HTTP caches (browser, CDN); the library manages an in-memory cache of application data. They complement each other: the library avoids requests, and when it does request, the header lets the CDN answer instantly.
Which should I choose, SWR or TanStack Query?
SWR is minimal and focused on reading data; TanStack Query offers more control over mutations, invalidation, pagination and persistence. Both implement the same caching idea well; choose based on how much mutation and cache-management functionality you need.
Does client caching help Core Web Vitals?
It helps route transitions and interaction responsiveness in SPAs (fewer loading states, fewer renders). It does not affect the first page load, where server rendering and HTTP caching matter more.
How do I avoid stale data after a user's own change?
Invalidate or update the affected queries in the mutation's success handler, or use optimistic updates that write the expected result into the cache immediately.
Should staleTime match the API's Cache-Control?
Roughly. A client staleTime much shorter than the CDN's s-maxage produces refetches that the CDN answers from cache — cheap, but pointless for freshness. Aligning them avoids requests that cannot return new data.
Does the query cache use memory on low-end devices?
Yes, proportionally to the data kept. gcTime controls how long unused entries persist; lower it for apps with large datasets, or avoid caching very large responses in memory.
Related
- Choosing SWR windows for HTML and APIs — server-side staleness windows to pair with these settings.
- Caching API responses at the CDN — the edge layer under the client cache.
- Tracking route transition time with User Timing — measuring the effect.