Prefetching & Cache Warming
Every visible loading spinner is a race the client already lost: the user expressed intent, the component mounted, and only then did the request leave the browser. Prefetching and cache warming close that gap by moving the fetch earlier — to the moment a link enters the viewport, when a pointer hovers a row, or while a router resolves the next route — so that by the time the destination component subscribes, its data is already sitting in the cache. This is a synchronization discipline that sits alongside the rest of Cache Invalidation & Server Synchronization: where invalidation decides when cached data becomes wrong, warming decides when cached data first appears.
The mechanics are deceptively subtle. A naive prefetchQuery call that ignores staleTime will populate the cache and then immediately throw the work away when the real useQuery mounts and decides the entry is stale. Seeding a detail view from a list row with the wrong primitive — initialData versus placeholderData — either suppresses a needed background fetch or triggers one you did not want. This guide covers the three warming primitives React Query v5 exposes (prefetchQuery, ensureQueryData, and setQueryData), how to trigger them on intent and on navigation, and how staleTime is the single setting that determines whether your warm-up actually pays off. It composes directly with Stale-While-Revalidate Implementation, which governs how a warmed entry stays fresh across subsequent views.
Diagnostic Checklist
You are looking at a prefetching or cache-warming problem — rather than a slow API — if you observe:
- A spinner flashes on a detail page even though the user hovered its link for a full second before clicking.
prefetchQueryruns (you see the request in the Network tab) but the destinationuseQueryfires a second identical request on mount.- Seeding a detail view with
setQueryDatapaints instantly, but the value is missing fields the list never carried, and no background fetch fills them in. initialDatayou passed touseQuerynever gets replaced by fresh server data, leaving a permanently stale detail view.- Route transitions feel instant on fast connections but still spinner-heavy on 3G because the loader awaits nothing and the component fetches on mount.
- Rapid hovering across a long list fires dozens of prefetch requests, saturating the connection and delaying the one request that matters.
- React Query DevTools shows a prefetched entry in
stalestate the instant it is written, so every consumer treats it as needing a refetch.
Prerequisites
Before applying the patterns below you should be comfortable with:
staleTimeversusgcTime: knowing thatstaleTimecontrols when an entry is eligible for a background refetch, whilegcTimecontrols when an unused entry is evicted from memory. Warming depends almost entirely onstaleTime.- Query keys: understanding that a prefetch only warms a consumer if both use an identical, stably-serialized
queryKey— a mismatch produces two independent cache entries. - The
QueryClientinstance: having a single shared client (viaQueryClientProvider) so imperativeprefetchQuery/ensureQueryDatacalls write to the same cache the components read from. - Background revalidation: the freshness lifecycle detailed in Stale-While-Revalidate Implementation, which determines what happens to a warmed entry after its first read.
Implementation 1 — Prefetch on Hover and Intent
The highest-leverage warming trigger is user intent. A pointer hovering a link, or keyboard focus landing on it, is a strong signal that a navigation is imminent — typically 200-400ms before the click. Firing prefetchQuery on that event gives the network a head start that almost always covers the perceived latency of the transition.
Steps:
- Get the shared client with
useQueryClientinside the component that renders the link or row. - Attach an
onMouseEnterandonFocushandler that callsqueryClient.prefetchQuerywith the exactqueryKeyandqueryFnthe destination page uses. - Pass a
staleTimeon the prefetch so the warmed entry is considered fresh when the destination mounts. - Do nothing on unhover — a fired prefetch is cheap to keep, and cancelling it would waste the round-trip already in flight.
import { useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
interface Article {
id: string;
title: string;
body: string;
author: string;
}
const articleQuery = (id: string) => ({
queryKey: ['article', id] as const,
queryFn: async (): Promise<Article> => {
const res = await fetch(`/api/articles/${id}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<Article>;
},
staleTime: 60_000,
});
export function ArticleLink({ id, title }: { id: string; title: string }) {
const queryClient = useQueryClient();
const warm = () => {
// Fire-and-forget: prefetchQuery resolves regardless of outcome.
void queryClient.prefetchQuery(articleQuery(id));
};
return (
<Link to={`/articles/${id}`} onMouseEnter={warm} onFocus={warm}>
{title}
</Link>
);
}
Cache Behavior Impact: prefetchQuery checks the cache before issuing a request. If an entry for ['article', id] already exists and is still within its staleTime window, the call returns immediately without touching the network — repeated hovers over the same link are therefore free after the first. When the destination page mounts a useQuery(articleQuery(id)), it finds a fresh entry (because the prefetch used the same staleTime: 60_000) and renders synchronously with no mount-time refetch. Sharing a single articleQuery factory guarantees the key and staleTime match on both sides, which is the whole precondition for the warm-up to count.
Configuration Trade-offs:
staleTime: 60_000must be greater than the expected hover-to-mount delay; if it were0, the entry would be stale the instant it was written anduseQuerywould refetch on mount, wasting the prefetch entirely.- Because
prefetchQueryreturns a promise that never rejects, wrap it invoidand never rely on it for error handling — useensureQueryDatawhen the failure matters. gcTime(default 5 minutes) determines how long an un-clicked prefetch survives; a long list of hovered-but-abandoned links will retain entries untilgcTimeelapses, so keep it modest on memory-constrained targets.- Prefetching on
onFocusas well asonMouseEnteris essential for keyboard and screen-reader users, who never generate pointer events; omitting it silently regresses accessibility performance.
Implementation 2 — Warm on Route Change with ensureQueryData
Hover prefetch is opportunistic; route-change warming is guaranteed. When a router resolves a route, you know exactly which data the destination needs, and you can block the transition just long enough to warm the cache. React Query v5’s ensureQueryData is the right primitive here: unlike prefetchQuery, it returns the data and rejects on failure, so a loader can await it and surface errors through the router’s error boundary.
Steps:
- Define the loader for the route so it receives the route params (e.g. the entity id).
- Call
queryClient.ensureQueryDatawith the destination’s query options andawaitit, so navigation resolves only once the entity is in cache. - Return nothing meaningful from the loader — the point is the side effect of warming the shared cache, not passing data through the router.
- In the component, call
useQuerywith identical options; it reads the warmed entry synchronously.
import { QueryClient, useQuery } from '@tanstack/react-query';
import { LoaderFunctionArgs, useParams } from 'react-router-dom';
interface Project {
id: string;
name: string;
tasks: number;
}
const projectQuery = (id: string) => ({
queryKey: ['project', id] as const,
queryFn: async (): Promise<Project> => {
const res = await fetch(`/api/projects/${id}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<Project>;
},
staleTime: 120_000,
});
// Loader factory bound to the app's single QueryClient.
export const projectLoader =
(queryClient: QueryClient) =>
async ({ params }: LoaderFunctionArgs) => {
const id = params.id as string;
// ensureQueryData returns cached data if fresh, else awaits the fetch.
await queryClient.ensureQueryData(projectQuery(id));
return null;
};
export function ProjectPage() {
const { id } = useParams();
// Reads the entry the loader already warmed — no spinner on entry.
const { data } = useQuery(projectQuery(id!));
return (
<section>
<h2>{data?.name}</h2>
<p>{data?.tasks} open tasks</p>
</section>
);
}
Cache Behavior Impact: ensureQueryData first inspects the cache: if a fresh entry exists it resolves with that value and issues no request, which means a route entered moments after a hover prefetch costs nothing. If the entry is missing or stale, it delegates to fetchQuery, awaits the result, writes it to the cache, and returns it. Because the loader awaits this, the router will not commit the transition until the data is present — so when ProjectPage mounts, its useQuery finds a fresh entry under staleTime: 120_000 and renders without a loading state. Sharing the projectQuery factory again keeps the loader and the component pointed at one cache slot.
Configuration Trade-offs:
ensureQueryDatarejects on fetch failure, so it belongs in a loader with an error boundary; usingprefetchQueryhere would swallow the error and let the component mount into an empty cache and spin.staleTime: 120_000should reflect how long the route data can safely be reused across back/forward navigations; too short and each revisit re-blocks the transition, too long and stale entities linger past their usefulness.- Awaiting in the loader trades a brief pre-navigation delay for a spinner-free destination; if the endpoint is slow, consider not awaiting (fire the
ensureQueryDatawithoutawait) so the component renders a skeleton while the fetch completes. - Because the warmed data lives only in the cache, keep
gcTimecomfortably above the route’s expected dwell time so a quick detour and return does not evict it.
Implementation 3 — Seed Detail Views from a List with setQueryData, initialData, and placeholderData
Lists usually carry enough of each entity to render the top of its detail view. Rather than fetch the detail cold, you can seed the detail query key with the row you already hold — either eagerly by writing it with setQueryData, or lazily by supplying initialData / placeholderData at the consuming useQuery. The choice between the three is the most misunderstood part of warming, because it decides whether a background fetch runs.
Steps:
- When rendering a list, hold each row’s entity keyed by id so it can seed the detail query.
- To seed eagerly, call
queryClient.setQueryData(['entity', id], row)— but only if the row is a complete representation. - To seed lazily from a partial row, pass
placeholderDatato the detailuseQueryso it renders instantly yet still fetches the full record. - Reserve
initialDatafor the case where the seed is complete and should count againststaleTime, suppressing the background fetch.
import { useQueryClient, useQuery } from '@tanstack/react-query';
interface UserRow { id: string; name: string }
interface UserDetail { id: string; name: string; bio: string; joinedAt: string }
const userDetailKey = (id: string) => ['user', id] as const;
async function fetchUser(id: string): Promise<UserDetail> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json() as Promise<UserDetail>;
}
// Detail hook seeded from a partial list row via placeholderData.
export function useUserDetail(id: string) {
const queryClient = useQueryClient();
return useQuery({
queryKey: userDetailKey(id),
queryFn: () => fetchUser(id),
staleTime: 30_000,
// placeholderData renders instantly but is treated as stale,
// so the full record is still fetched in the background.
placeholderData: () => {
const rows = queryClient.getQueryData<UserRow[]>(['users', 'list']);
const row = rows?.find((r) => r.id === id);
return row
? ({ id: row.id, name: row.name, bio: '', joinedAt: '' } as UserDetail)
: undefined;
},
});
}
// Eager seed: only safe when the row is a COMPLETE entity.
export function seedUserDetail(
queryClient: ReturnType<typeof useQueryClient>,
user: UserDetail,
) {
queryClient.setQueryData(userDetailKey(user.id), user);
}
Cache Behavior Impact: The three primitives sit at different points on the freshness axis. setQueryData writes a real cache entry that inherits the consuming query’s staleTime, so a seeded-then-fresh entry suppresses the mount-time fetch entirely — correct only when the seed is complete. placeholderData never touches the cache: it is a render-only fallback that React Query shows while the query is pending, and because the query is still considered to have no real data, the queryFn runs immediately to fetch the authoritative record. initialData (not shown but contrasted here) does write to the cache and does count against staleTime, so a fresh initialData value stops the background fetch — which is why it must only be used for trustworthy, complete seeds. Choosing placeholderData for partial rows gives you an instant paint plus a guaranteed refresh; choosing initialData for partial rows leaves the missing fields permanently blank until an unrelated invalidation.
Configuration Trade-offs:
- Use
placeholderDatawhen the seed is partial: it renders instantly, keepsisPlaceholderDatatrue so the UI can show a subtle loading affordance, and always triggers the backgroundqueryFn. - Use
initialDataonly for complete seeds; combine it withinitialDataUpdatedAtif the source has a known timestamp, sostaleTimeis measured from when the data was actually fetched rather than when it was seeded. setQueryDatais imperative and eager — ideal in a mutation’sonSuccessor right before navigation — but writing a partial object there is a latent bug, because the consumer’sstaleTimemay then suppress the fetch that would have completed it.- All three depend on the seed and the consumer sharing an identical
queryKey; a['user', id]seed will not warm a['users', 'detail', id]consumer, producing a silent cache miss.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Prefetched data refetches the instant the destination mounts | The prefetch used staleTime: 0 (or omitted it), so the entry is stale before useQuery reads it |
Set the same non-zero staleTime on both the prefetchQuery call and the consuming useQuery; share a query-options factory so they cannot drift |
| Hover fires a request but the click still shows a spinner | The prefetch queryKey does not exactly match the destination’s key, creating two separate cache entries |
Log both keys or inspect DevTools; unify them through a single xxxQuery(id) factory so serialization is identical |
| Seeded detail view is missing fields and never fills them in | A partial row was written with initialData or setQueryData, so the fresh entry satisfies staleTime and the background fetch is suppressed |
Switch the partial seed to placeholderData, which renders instantly but leaves the query pending so the queryFn still runs |
| Rapid hovering across a list saturates the network | Every onMouseEnter fires a new prefetchQuery with staleTime: 0, so none of them dedupe |
Give the prefetch a staleTime so repeat hovers hit the fresh-entry short-circuit; optionally debounce the handler by ~100ms |
| Route loader errors crash silently and the page spins forever | The loader used prefetchQuery, which never rejects, so a failed fetch left the cache empty with no error surfaced |
Use ensureQueryData in loaders so failures reject and route through the router’s error boundary |
Frequently Asked Questions
Why does my prefetched data refetch immediately when the component mounts?
Because the prefetch wrote data with staleTime: 0, so it is already stale when useQuery subscribes. prefetchQuery only avoids a refetch on mount if the entry is still fresh — set the same staleTime on both the prefetch call and the useQuery call so the mount-time freshness check passes and the cached value is served without a network round-trip. The cleanest way to guarantee this is a shared query-options factory that both sides import.
Should I use initialData or placeholderData when seeding a detail view from a list?
Use placeholderData when the list row is a partial projection of the detail entity — it renders instantly but is treated as stale, so React Query still fetches the full record in the background and you can show a loading affordance via isPlaceholderData. Use initialData only when the seeded value is complete and trustworthy enough to satisfy staleTime, because initialData is written into the cache as a real, persisted entry and suppresses the background fetch until it goes stale. Seeding a partial row with initialData is the classic “detail view stuck with blank fields” bug.
Does prefetchQuery throw if the network request fails?
No. queryClient.prefetchQuery swallows errors by design so a failed warm-up never crashes the interaction that triggered it — it resolves regardless of outcome. If you need the error, use ensureQueryData, which returns the cached data or the resolved promise and rejects on failure, making it the correct primitive inside a route loader that must block navigation until data is available. Reserve prefetchQuery for opportunistic, fire-and-forget warming where a failure should simply fall back to a normal fetch on mount.
How is prefetching different from the stale-while-revalidate pattern?
Stale-while-revalidate serves an existing cached value instantly and refetches in the background after the user has already requested the data. Prefetching populates the cache before the user requests anything, so the very first render is instant with no visible fetch. The two compose cleanly: prefetch warms the entry, and stale-while-revalidate keeps it fresh on subsequent views using staleTime and background refetch. In practice you tune one staleTime that governs both behaviors.
Related
- Cache Invalidation & Server Synchronization — the parent section covering tag-based invalidation, optimistic updates, and the freshness model that determines what happens to a warmed entry after its first read.
- Stale-While-Revalidate Implementation — the companion pattern that keeps a warmed cache entry fresh on subsequent views, sharing the same
staleTimelever that makes prefetching pay off. - Prefetching Queries on Hover and Route Change — the focused recipe for wiring
onMouseEnter/onFocushandlers and route loaders, including deduping repeat hovers and cancelling wasted prefetches. - Background Refetch Strategies — how
refetchInterval,refetchOnWindowFocus, and revalidation timing interact with entries you warmed ahead of time.