Stale-While-Revalidate Implementation

The stale-while-revalidate (SWR) pattern solves a specific engineering problem: eliminating the perceived loading gap between navigations and data refreshes without risking permanently stale UI. It does this by serving whatever is in the cache immediately, then silently fetching a fresh copy and reconciling — all part of the Cache Invalidation & Server Synchronization discipline. Done correctly, this transforms cache management from a point-in-time snapshot into a self-healing synchronization loop. Done wrong, it produces stale data that never refreshes, redundant network floods, and hydration mismatches that flash blank content on load.

This page also relates to Background Refetch Strategies, which covers the triggering mechanisms (focus, reconnect, polling intervals) that feed into this lifecycle, and to Mutation Sync & Rollback, which governs what happens to the SWR loop after a write operation lands.


Diagnostic Checklist

You likely need this page if you observe any of the following:

  • Components flash stale data for several seconds after a server-side write
  • Network DevTools shows the same endpoint called three or four times on a single page load
  • staleTime: Infinity suppresses background fetches permanently but you cannot decide on the right TTL
  • The app briefly renders correct data, then reverts to an older snapshot after a route change
  • SSR-rendered HTML contains fresh data but the client immediately fires a duplicate request on mount
  • gcTime (or cacheTime in TanStack Query v4) is set to 0 and queries “forget” data the instant a component unmounts, causing visible loading spinners on every back-navigation

Prerequisites

Before configuring the SWR lifecycle, make sure you understand:

  • Deterministic cache keys — keys must be structurally stable across renders; see Designing Stable Query Keys for React Query for the exact rules
  • Server vs. client state boundaries — SWR applies exclusively to server-fetched data; mixing it with local UI state causes unpredictable invalidation chains (see Client vs. Server State Boundaries)
  • staleTime vs. gcTimestaleTime controls when a background refetch is triggered; gcTime controls when the cache entry is evicted from memory after all observers unmount — these are orthogonal axes

SWR Lifecycle Diagram

The four-phase cycle below governs every SWR implementation regardless of library:

Stale-While-Revalidate Lifecycle Diagram showing the four deterministic phases of the SWR cache lifecycle: (1) Mount and cache lookup returns cached data immediately, (2) Freshness evaluation checks staleTime TTL, (3) Background refetch with deduplication fires a single network request, (4) Conditional UI reconciliation updates the component only when payload differs. 1. Mount & Cache Lookup Serve cached data synchronously 2. Freshness Evaluation Compare timestamp vs staleTime TTL 3. Background Refetch & Dedup One network call per key regardless 4. Conditional UI Reconciliation Re-render only when payload structurally differs fresh → suppress refetch

Implementation 1 — TanStack Query (React Query v5)

Steps

  1. Install @tanstack/react-query v5 and wrap your app in QueryClientProvider.
  2. Set staleTime to the maximum duration your UI can tolerate stale data (not the server’s TTL — they are different axes).
  3. Set gcTime to determine how long the entry survives in memory after the last subscriber unmounts.
  4. Pass signal from the query context to every fetch call to enable automatic cancellation on unmount or key change.
  5. Use keepPreviousData (the v5 API is placeholderData: keepPreviousData) to prevent layout shift during key transitions (e.g. paginated queries).
import { useQuery, keepPreviousData, QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,   // 5 min: suppress background refetch for fresh data
      gcTime: 1000 * 60 * 30,     // 30 min: retain in memory after unmount
      refetchOnWindowFocus: true,  // revalidate on tab re-focus
      retry: 2,
    },
  },
});

// Per-query override for a high-frequency dashboard widget
function useDashboardMetrics(tenantId: string) {
  return useQuery({
    queryKey: ['dashboard-metrics', tenantId],
    queryFn: async ({ signal }) => {
      const res = await fetch(`/api/tenants/${tenantId}/metrics`, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
      return res.json() as Promise<DashboardMetrics>;
    },
    staleTime: 1000 * 30,           // 30 s: override — metrics go stale faster
    gcTime: 1000 * 60 * 5,          // 5 min: evict sooner, this widget is transient
    placeholderData: keepPreviousData, // hold prior tenant's data during tenantId transition
    refetchInterval: 1000 * 60,     // 1 min polling for this widget only
  });
}

Cache Behavior Impact: When useDashboardMetrics mounts, TanStack Query performs a synchronous cache lookup by ['dashboard-metrics', tenantId]. If an entry exists and its updatedAt is within staleTime, the cached payload is returned and no network request fires. If stale (or absent), the query is scheduled for a background fetch. The shared QueryClient promise registry deduplicates: if two components mount simultaneously with the same tenantId, only one fetch call goes to the network. The signal parameter automatically aborts the in-flight request if the component unmounts or tenantId changes before the response arrives. structuralSharing (enabled by default) deep-compares the incoming payload against the cached value — if they are equal, the cache reference is not replaced and React skips re-rendering subscribers.

Configuration Trade-offs:

  • Setting staleTime too low (< 1 s) effectively disables caching: every mount triggers a background fetch, multiplying API load proportionally to component tree depth.
  • gcTime: 0 mirrors the old React Query v3 behavior of immediately evicting on unmount — avoid unless you have a strong reason, as it eliminates the “instant back-navigation” UX benefit.
  • refetchInterval and refetchOnWindowFocus: true stack: a widget can refetch both on a timer and on focus, doubling requests during active use. Disable one or gate refetchInterval behind a focus check using refetchIntervalInBackground: false.
  • placeholderData: keepPreviousData holds the previous query’s data during key transitions, preventing spinners on pagination or tenant switches, but the displayed data is technically stale for the new key until the fetch resolves.

Implementation 2 — SWR v2 (Vercel)

Steps

  1. Install swr v2 and configure a global SWRConfig with shared defaults.
  2. Set dedupingInterval to collapse concurrent requests for the same key within a time window.
  3. Use fallbackData (not initialData) to seed the cache without triggering a revalidation; use fallback in SWRConfig for SSR-prefetched maps.
  4. Attach a custom fetcher at the config level to centralize error handling and auth headers.
import useSWR, { SWRConfig } from 'swr';

// Global SWR configuration — wrap at app root
function AppRoot({ children, prefetchedCache }: { children: React.ReactNode; prefetchedCache: Record<string, unknown> }) {
  return (
    <SWRConfig
      value={{
        fetcher: async (url: string) => {
          const res = await fetch(url, { credentials: 'include' });
          if (!res.ok) throw new Error(`SWR fetch failed: ${res.status}`);
          return res.json();
        },
        revalidateOnFocus: true,
        revalidateOnReconnect: true,
        dedupingInterval: 2000,     // collapse identical requests within 2 s window
        errorRetryCount: 3,
        fallback: prefetchedCache,  // seed cache with server-fetched data (SSR pattern)
      }}
    >
      {children}
    </SWRConfig>
  );
}

// Usage — SWR resolves the fetcher from context
function UserCard({ userId }: { userId: string }) {
  const { data, error, isValidating, mutate } = useSWR<UserProfile>(
    `/api/users/${userId}`,
    {
      revalidateOnMount: true,     // always revalidate on first mount (even if fresh)
      keepPreviousData: true,      // SWR v2 API — mirrors TanStack's placeholderData
    }
  );

  if (error) return <ErrorBanner message={error.message} />;

  return (
    <div aria-busy={isValidating}>
      <h2>{data?.displayName ?? 'Loading…'}</h2>
      <button onClick={() => mutate()}>Refresh</button>
    </div>
  );
}

Cache Behavior Impact: SWR maintains a global in-memory Map keyed by the string URL. On mount, it performs a synchronous read from that map and returns the value immediately if present, regardless of freshness. Revalidation is then scheduled asynchronously (unless revalidateOnMount: false). dedupingInterval: 2000 means all useSWR('/api/users/42') calls within the same 2-second window share a single in-flight fetch promise — subsequent callers receive the resolved value without firing an additional network request. fallback in SWRConfig pre-populates the global cache on initial render, so the component returns server data on the first synchronous read and only revalidates after mount rather than starting empty.

Configuration Trade-offs:

  • SWR’s cache is in-memory only by default — it does not persist to localStorage or IndexedDB. A full page refresh clears all cached data, which makes fallback hydration critical for SSR applications.
  • revalidateOnMount: true combined with fallback data means SWR will always fire a background network request on first mount even when the server-provided data is fresh. Set revalidateIfStale: false to suppress this when server data can be trusted as current.
  • keepPreviousData: true (SWR v2) is structurally equivalent to placeholderData: keepPreviousData in TanStack Query v5 — but SWR applies it at the hook level, not the client default level, so it must be set per hook or via SWRConfig.
  • dedupingInterval is a blunt instrument: requests within the window are collapsed regardless of whether the cached value is stale. Setting it too high (> 10 s) can suppress legitimate revalidations after mutations.

Implementation 3 — Vanilla JS with AbortController

For micro-frontends, Web Components, or environments without a state library, a Map-based cache with promise deduplication and AbortController cancellation replicates the essential SWR contract without dependencies.

Steps

  1. Maintain a module-level Map keyed by URL for data entries, and a separate Map for in-flight promise handles (deduplication registry).
  2. On each swrFetch call: if a cached entry is within staleTime, return it synchronously and fire a non-blocking background update.
  3. Before firing a background fetch, check the in-flight registry. If a request for the same key is already running, attach to its promise instead of creating a new one.
  4. Clean up the in-flight registry entry after the promise settles (success or error).
const dataCache = new Map();   // url → { data, timestamp }
const inFlight = new Map();    // url → Promise<unknown>

/**
 * staleTime defaults to 30 s. Pass options.signal to cancel on unmount.
 * Background refetches intentionally suppress errors to avoid crashing callers
 * who already received stale data.
 */
async function swrFetch(url, { staleTime = 30_000, signal } = {}) {
  const now = Date.now();
  const cached = dataCache.get(url);

  if (cached && now - cached.timestamp < staleTime) {
    // Serve stale data synchronously; fire background update if not already in flight
    if (!inFlight.has(url)) {
      const promise = fetch(url)
        .then((res) => {
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json();
        })
        .then((data) => {
          dataCache.set(url, { data, timestamp: Date.now() });
        })
        .catch(() => { /* suppress background errors — stale data remains */ })
        .finally(() => inFlight.delete(url));

      inFlight.set(url, promise);
    }
    return cached.data;
  }

  // No usable cache — foreground fetch (deduplicating concurrent calls)
  if (inFlight.has(url)) {
    await inFlight.get(url);         // wait for the in-flight request
    return dataCache.get(url)?.data; // return whatever it cached
  }

  const promise = fetch(url, { signal })
    .then((res) => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then((data) => {
      dataCache.set(url, { data, timestamp: Date.now() });
      return data;
    })
    .finally(() => inFlight.delete(url));

  inFlight.set(url, promise);
  return promise;
}

Cache Behavior Impact: The two-Map design separates concerns: dataCache is the persistence layer (survives async gaps) and inFlight is the deduplication layer (lifespan of one request). When a component calls swrFetch while another is already fetching the same URL, the second caller awaits the shared promise and reads from dataCache after it resolves — exactly zero additional network calls. Passing signal to the foreground fetch allows the browser to abort the TCP connection if the caller’s AbortController fires (e.g. on component unmount via a useEffect cleanup). Background refetch errors are swallowed by design: the caller already has stale data, and surfacing a background error would require a re-render that the caller is not prepared to handle.

Configuration Trade-offs:

  • This pattern has no subscriber model: there is no way to notify multiple components when the background fetch resolves. Pairing it with a BroadcastChannel or a simple EventEmitter fills this gap for multi-component scenarios.
  • staleTime is per-call, not global — forgetting to pass a consistent value across callers creates split-brain: one component considers data fresh while another triggers a refetch.
  • Background error suppression (catch(() => {})) means failed updates leave stale data indefinitely. Add a “retry with backoff” loop if the endpoint is critical-path.
  • The inFlight map must be module-level (not component-level) to achieve true deduplication across concurrent component instances.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Component re-renders twice on mount: once with stale data, once with fresh staleTime: 0 (the default) means every cached entry is immediately considered stale; the background refetch fires on every mount and triggers a second render Set staleTime to a value that covers typical user interaction cycles — even 5 seconds eliminates the double-render in most navigation patterns
Three identical API calls appear in DevTools on a single page load Three components mount simultaneously, each calling useQuery / useSWR with the same key before the first fetch resolves; deduplication only applies if the calls land within dedupingInterval (SWR) or before the first promise settles (TanStack Query) Verify keys are structurally identical (same string/array shape) and that QueryClientProvider / SWRConfig wraps all three components at a shared ancestor
SSR page flashes empty then repopulates immediately after hydration Client cache initializes empty; React hydrates with server HTML but useQuery returns undefined on the first client render Use TanStack Query’s dehydrate / HydrationBoundary or SWR’s fallback in SWRConfig to pre-populate the client cache before the first render; align staleTime to be longer than the server-to-client hydration window
gcTime set to 0 causes “Loading…” spinner on every back-navigation With gcTime: 0, TanStack Query evicts the cache entry the instant the last subscriber unmounts (e.g. on route change); the next mount finds an empty cache and must foreground-fetch Increase gcTime to at least 5 minutes for navigation-heavy applications; the memory cost is negligible and eliminates spinner regressions
Background refetch fires every second regardless of staleTime refetchInterval is set to a value shorter than the sum of fetch latency + staleTime; refetchIntervalInBackground: true keeps the timer running even when the tab is hidden Align refetchInterval to be strictly greater than expected round-trip time, and set refetchIntervalInBackground: false to pause polling in hidden tabs

Frequently Asked Questions

Why does my TanStack Query component refetch on every render even with staleTime set?

The queryKey is most likely re-created as a new object reference on each render. Even though TanStack Query v5 uses structural equality for key comparison, an inline object literal passed directly as part of the key array — queryKey: ['user', { id: userId, filters }] where filters is an object from state — will still be compared correctly by value. The more common culprit is a key that wraps an unstable function reference or a Date object. Ensure every element of your key array serializes to a stable JSON value. Use JSON.stringify on the array mentally to check: if the stringified form changes between renders, the key changes and a refetch fires.

Can SWR replace real-time WebSocket subscriptions?

No. SWR optimizes for eventual consistency: it refreshes data on a schedule (polling, focus, reconnect) and tolerates latency measured in seconds. WebSockets provide push-based delivery initiated by the server, with latency measured in milliseconds. Collaborative editing, live order books, live dashboards driven by server-push events, and multiplayer game state all require WebSockets (or SSE). SWR is the right tool for REST-style resources where sub-second freshness is not a requirement.

How does gcTime interact with an active background refetch in TanStack Query?

gcTime starts counting only after all observers (mounted components) have unmounted. An active background refetch requires at least one active observer, so while a refetch is in progress, gcTime cannot start. Once the last observer unmounts and the refetch settles, gcTime begins. If a component remounts before gcTime expires, the cache entry is reused and gcTime resets. This means a rapidly cycling route (mount → unmount → mount within the gcTime window) will always find warm cache, which is the intended behavior.

What is the correct way to prevent a hydration double-fetch in Next.js with TanStack Query?

Use the dehydrate / HydrationBoundary pattern. On the server, create a QueryClient, call prefetchQuery for each query the page needs, then call dehydrate(queryClient) and pass the result as a prop. On the client, wrap your component tree in <HydrationBoundary state={dehydratedState}>. Crucially, set staleTime on the prefetched queries to be longer than the time between server render and client hydration (typically 2–5 seconds suffices) — otherwise TanStack Query considers the server-fetched data stale on the first client render and fires an immediate background request, duplicating the work the server already did.