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.


Cold fetch versus warmed cache timeline Top lane shows a cold path where the fetch only starts at component mount, leaving a visible spinner window. Bottom lane shows an intent-triggered prefetch that starts at hover, resolves into the cache before mount, and lets the component render instantly from a fresh entry. COLD PATH — fetch on mount WARM PATH — prefetch on intent mount fetch starts here network round-trip data ready spinner visible hover / focus prefetchQuery network round-trip written to cache (fresh) mount instant render staleTime keeps it fresh
On the cold path the fetch cannot start until mount, so a spinner is unavoidable. On the warm path the fetch starts at intent, lands in the cache as a fresh entry, and the mount reads it synchronously.

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.
  • prefetchQuery runs (you see the request in the Network tab) but the destination useQuery fires a second identical request on mount.
  • Seeding a detail view with setQueryData paints instantly, but the value is missing fields the list never carried, and no background fetch fills them in.
  • initialData you passed to useQuery never 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 stale state 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:

  • staleTime versus gcTime: knowing that staleTime controls when an entry is eligible for a background refetch, while gcTime controls when an unused entry is evicted from memory. Warming depends almost entirely on staleTime.
  • 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 QueryClient instance: having a single shared client (via QueryClientProvider) so imperative prefetchQuery / ensureQueryData calls 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:

  1. Get the shared client with useQueryClient inside the component that renders the link or row.
  2. Attach an onMouseEnter and onFocus handler that calls queryClient.prefetchQuery with the exact queryKey and queryFn the destination page uses.
  3. Pass a staleTime on the prefetch so the warmed entry is considered fresh when the destination mounts.
  4. 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_000 must be greater than the expected hover-to-mount delay; if it were 0, the entry would be stale the instant it was written and useQuery would refetch on mount, wasting the prefetch entirely.
  • Because prefetchQuery returns a promise that never rejects, wrap it in void and never rely on it for error handling — use ensureQueryData when 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 until gcTime elapses, so keep it modest on memory-constrained targets.
  • Prefetching on onFocus as well as onMouseEnter is 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:

  1. Define the loader for the route so it receives the route params (e.g. the entity id).
  2. Call queryClient.ensureQueryData with the destination’s query options and await it, so navigation resolves only once the entity is in cache.
  3. Return nothing meaningful from the loader — the point is the side effect of warming the shared cache, not passing data through the router.
  4. In the component, call useQuery with 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:

  • ensureQueryData rejects on fetch failure, so it belongs in a loader with an error boundary; using prefetchQuery here would swallow the error and let the component mount into an empty cache and spin.
  • staleTime: 120_000 should 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 ensureQueryData without await) so the component renders a skeleton while the fetch completes.
  • Because the warmed data lives only in the cache, keep gcTime comfortably 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:

  1. When rendering a list, hold each row’s entity keyed by id so it can seed the detail query.
  2. To seed eagerly, call queryClient.setQueryData(['entity', id], row) — but only if the row is a complete representation.
  3. To seed lazily from a partial row, pass placeholderData to the detail useQuery so it renders instantly yet still fetches the full record.
  4. Reserve initialData for the case where the seed is complete and should count against staleTime, 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 placeholderData when the seed is partial: it renders instantly, keeps isPlaceholderData true so the UI can show a subtle loading affordance, and always triggers the background queryFn.
  • Use initialData only for complete seeds; combine it with initialDataUpdatedAt if the source has a known timestamp, so staleTime is measured from when the data was actually fetched rather than when it was seeded.
  • setQueryData is imperative and eager — ideal in a mutation’s onSuccess or right before navigation — but writing a partial object there is a latent bug, because the consumer’s staleTime may 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.