Background Refetch Strategies

Background refetch is the mechanism that keeps UI state consistent with the server without requiring user interaction. When it breaks — timers stack up across route transitions, polls fire into blank browser tabs, or reconnections overwrite locally queued mutations — the failure is invisible until a customer reports stale data or a mobile session drains the battery. This page covers the three concrete sub-problems: adaptive polling, visibility-driven revalidation, and network-aware fetching with offline recovery.

This is a sub-topic of Cache Invalidation & Server Synchronization, the discipline governing how client caches stay in sync with upstream state. Related concerns — what to do when a write fails mid-flight — are covered in Mutation Sync & Rollback, and the narrower question of tuning refresh intervals in SWR specifically lives in Optimizing SWR Revalidation Intervals.


Diagnostic Checklist

You are in the right place if you observe any of the following:

  • UI shows stale data after the user returns to a tab that was idle for minutes
  • DevTools shows polling requests firing continuously even when the window is blurred
  • Memory usage climbs after repeated route navigation, suggesting timer accumulation
  • Background fetches overwrite optimistic mutation state, causing visible flicker or rollback
  • Network requests spike when a device reconnects from offline, causing server 429s
  • refetchInterval callbacks are evaluated but intervals do not pause on mobile background tabs

Prerequisites

Before implementing background refetch, make sure you understand:

  • How staleTime and gcTime govern when TanStack Query considers a cache entry fresh versus eligible for garbage collection — these thresholds directly control whether a background refetch fires at all
  • The stale-while-revalidate pattern, which is the conceptual model background refetch implements: serve the cached value immediately, then silently replace it with the fresh result
  • How tag-based invalidation systems scope invalidation to specific entities, which affects which queries should poll and which should wait for a targeted invalidation event

Architecture: How Background Refetch Fits the Cache Lifecycle

The diagram below shows the decision path a background refetch scheduler follows on every tick, from checking staleTime through visibility gating to the actual network call and cache write.

Background Refetch Decision Flow A flowchart showing the scheduler tick path: check staleTime, then check window visibility, then probe network, then fetch and write to cache or skip. Scheduler tick fires staleTime elapsed? Skip — serve cache No Yes Tab visible / focused? Pause interval (no request) No Yes Network reachable? Queue for retry (exponential backoff) No Fetch → write to cache Yes

Implementation 1: Adaptive Polling with Dynamic Intervals

Fixed-interval polling degrades server capacity and client memory. Production systems need schedules that scale with data volatility, active user engagement, and cache entropy.

Steps

  1. Replace any setInterval calls in component scope with the framework’s native refetchInterval option — this ensures the scheduler is torn down when the query loses active observers.
  2. Pass a callback to refetchInterval (TanStack Query v5) that reads the current cached data to decide whether a fetch is worth making. If lastUpdated is within the acceptable freshness window, return false.
  3. Drive the polling rate from a config object keyed by data criticality: financial tickers might poll every 5 s; a user profile might poll every 5 min.
  4. Add retryDelay with exponential backoff so that transient 5xx responses slow the scheduler rather than hammering the server.
import { useQuery, useQueryClient } from '@tanstack/react-query';

type Criticality = 'high' | 'medium' | 'low';

const POLL_INTERVALS: Record<Criticality, number> = {
  high: 5_000,    // 5 s  — financial data, presence indicators
  medium: 30_000, // 30 s — dashboard counters
  low: 300_000,   // 5 min — user profile, feature flags
};

interface DataPayload {
  lastUpdated: number;
  [key: string]: unknown;
}

function useAdaptivePolling(
  queryKey: unknown[],
  fetcher: () => Promise<DataPayload>,
  criticality: Criticality = 'medium',
) {
  const queryClient = useQueryClient();

  return useQuery<DataPayload>({
    queryKey,
    queryFn: fetcher,
    staleTime: POLL_INTERVALS[criticality] / 2,
    // refetchInterval callback receives the cached Query object
    refetchInterval: (query) => {
      const data = query.state.data;
      if (!data) return POLL_INTERVALS[criticality];

      const age = Date.now() - data.lastUpdated;
      // Skip the network call if the cache is still fresh
      if (age < POLL_INTERVALS[criticality]) return false;

      return POLL_INTERVALS[criticality];
    },
    refetchOnWindowFocus: true,
    // Exponential backoff: 1 s → 2 s → 4 s → 8 s (capped at 30 s)
    retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
    retry: 4,
  });
}

Cache Behavior Impact. When refetchInterval returns false, TanStack Query cancels the internal setTimeout without unmounting the observer. The cache entry remains active and staleTime continues to guard against redundant fetches triggered by other events (window focus, remount). retryDelay applies only to failed attempts — successful polls reset the failure counter and restore the normal interval. gcTime (default 5 min) controls when the entry is evicted if no components are observing it, independent of the polling schedule.

Configuration Trade-offs

  • staleTime set to half the poll interval prevents focus-triggered fetches from duplicating the scheduled poll. If staleTime is shorter than the interval, a window-focus event fires an extra request every time the user returns to the tab.
  • structuralSharing (enabled by default) means the cache write only triggers re-renders in components whose slice of the data actually changed — polling a large payload does not re-render every subscriber on every tick.
  • Avoid driving refetchInterval from React state (useState). State updates cause the hook to re-register the observer, which resets the internal timer and can cause interval drift.

Implementation 2: Visibility-Driven Revalidation

The Page Visibility API and window focus events let you suspend background synchronization entirely when the client is inactive. This prevents unnecessary network traffic, preserves battery on mobile, and eliminates the race condition where a blurred-tab poll returns after the user refocuses and overwrites a more recent in-memory state.

Steps

  1. Enable refetchOnWindowFocus: true in TanStack Query (it is the default) or revalidateOnFocus: true in SWR. Do not re-implement this with manual addEventListener unless you have a specific deduplication requirement.
  2. Wrap any custom focus listeners in a debounce of 100–300 ms. Rapid window switching (alt-tab, window snapping) otherwise spawns a burst of requests within a single second.
  3. Before the request dispatches, compare the cache lastUpdated timestamp against a maxAge threshold — if the entry is still fresh from a recent poll, abort without a network call.
  4. Integrate with Mutation Sync & Rollback so that focus-triggered background reads yield to in-flight optimistic writes. A stale background response that arrives after a mutation commit will silently overwrite locally applied changes, causing visible flicker.
import useSWR from 'swr';

const MAX_AGE_MS = 15_000; // treat cache as fresh for 15 s after last fetch

async function fetchWithTimeout(url: string): Promise<unknown> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 8_000);

  try {
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  } catch (err: unknown) {
    if (err instanceof Error && err.name === 'AbortError') {
      throw new Error('Background fetch timed out after 8 s');
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Custom fetcher that skips the network call when the cache is still warm
function makeStaleGuardedFetcher(maxAge: number) {
  let lastFetchedAt = 0;

  return async (url: string) => {
    const age = Date.now() - lastFetchedAt;
    // SWR calls this fetcher; returning the resolved value updates the cache
    if (age < maxAge && lastFetchedAt !== 0) {
      throw new Error('CACHE_FRESH'); // SWR ignores errors from dedupingInterval window
    }
    const data = await fetchWithTimeout(url);
    lastFetchedAt = Date.now();
    return data;
  };
}

function useFocusAwareData(url: string) {
  return useSWR(url, makeStaleGuardedFetcher(MAX_AGE_MS), {
    revalidateOnFocus: true,
    revalidateOnReconnect: true,
    // Prevents a second in-flight request for the same key within 2 s
    dedupingInterval: 2_000,
    // Do not retry on the CACHE_FRESH sentinel error
    shouldRetryOnError: (err: Error) => err.message !== 'CACHE_FRESH',
  });
}

Cache Behavior Impact. SWR’s dedupingInterval deduplications requests for the same key within the window, so rapid focus/blur cycles in quick succession produce only one network call. revalidateOnFocus fires independently of refreshInterval — the two mechanisms share the same deduplication boundary but have separate trigger paths. When AbortController cancels a timeout-exceeded request, SWR catches the rejection and transitions the key to an error state; shouldRetryOnError controls whether it then schedules a retry.

Configuration Trade-offs

  • revalidateOnFocus: false is the right call during active mutation sessions — set it dynamically by reading TanStack Query’s isMutating or a custom mutation-in-progress flag.
  • dedupingInterval defaults to 2 s in SWR v2. Increasing it reduces server load during rapid tab-switching but means the cache may reflect data that is dedupingInterval ms older than you expect after a focus event.
  • The Page Visibility API fires visibilitychange before focus. Libraries that listen for focus miss the tab-switch case on some browser/OS combinations. Prefer libraries that listen for both events (SWR and TanStack Query both do).

Implementation 3: Network-Aware Fetching and Offline Recovery

navigator.onLine is unreliable on mobile networks — it returns true on a captive portal or a link-local network with no upstream route. Production background fetchers need a probe-based connectivity check and a deterministic offline queue so that mutations and reads survive connectivity gaps without thundering-herd reconnects.

Steps

  1. Implement a lightweight connectivity probe: HEAD request to a known CDN asset, cached for 5–10 s to avoid probe overhead.
  2. If the probe fails, push pending mutations to an IndexedDB-backed queue sorted by priority (CRITICAL > HIGH > LOW).
  3. Register a navigator.connection change listener plus a window.addEventListener('online', ...) listener to trigger drain. Do not drain immediately on the first online event — add a 500 ms delay and re-verify the probe.
  4. On drain, replay queued mutations sequentially and check each response’s lastModified header against the cached state. Discard background payloads that predate locally applied mutations to prevent stale overwrites.
import { type MutationKey, useQueryClient } from '@tanstack/react-query';

interface QueuedMutation {
  priority: 'CRITICAL' | 'HIGH' | 'LOW';
  url: string;
  body: unknown;
  method: string;
  queryKey: MutationKey;
  queuedAt: number;
}

// Lightweight connectivity probe — caches result for 8 s
let probeCache: { online: boolean; expiresAt: number } | null = null;

async function isOnline(): Promise<boolean> {
  if (probeCache && probeCache.expiresAt > Date.now()) {
    return probeCache.online;
  }
  try {
    // HEAD request to a small, stable CDN asset
    const res = await fetch('https://www.cloudflare.com/cdn-cgi/trace', {
      method: 'HEAD',
      cache: 'no-store',
      signal: AbortSignal.timeout(3_000),
    });
    const online = res.ok;
    probeCache = { online, expiresAt: Date.now() + 8_000 };
    return online;
  } catch {
    probeCache = { online: false, expiresAt: Date.now() + 5_000 };
    return false;
  }
}

const PRIORITY_ORDER: Record<QueuedMutation['priority'], number> = {
  CRITICAL: 0,
  HIGH: 1,
  LOW: 2,
};

async function drainMutationQueue(
  queue: QueuedMutation[],
  queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
  // Sort by priority then insertion order
  const sorted = [...queue].sort(
    (a, b) =>
      PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority] ||
      a.queuedAt - b.queuedAt,
  );

  for (const item of sorted) {
    try {
      const res = await fetch(item.url, {
        method: item.method,
        body: JSON.stringify(item.body),
        headers: { 'Content-Type': 'application/json' },
        signal: AbortSignal.timeout(10_000),
      });
      if (res.ok) {
        // Invalidate — do not optimistically overwrite; let the server state win
        await queryClient.invalidateQueries({ queryKey: item.queryKey as string[] });
      }
    } catch {
      // Leave failed items in the queue for the next drain cycle
    }
  }
}

// Hook: wire up online listener and drain on reconnect
function useOfflineQueue() {
  const queryClient = useQueryClient();
  const queueRef = { current: [] as QueuedMutation[] };

  const drainOnReconnect = async () => {
    await new Promise((r) => setTimeout(r, 500)); // brief stabilisation pause
    if (await isOnline()) {
      await drainMutationQueue(queueRef.current, queryClient);
      queueRef.current = [];
    }
  };

  // In a real app, attach this listener in a top-level provider on mount
  // window.addEventListener('online', drainOnReconnect);

  return {
    enqueue: (mutation: Omit<QueuedMutation, 'queuedAt'>) =>
      queueRef.current.push({ ...mutation, queuedAt: Date.now() }),
    drain: drainOnReconnect,
  };
}

Cache Behavior Impact. Calling queryClient.invalidateQueries after a queued mutation drains marks the affected entries as stale and schedules a background refetch rather than writing a speculative value. This prevents the classic double-write: optimistic → server confirm → background poll → stale overwrite. TanStack Query’s internal refetch deduplification ensures that invalidating multiple queries during a drain does not produce parallel network calls for the same key.

Configuration Trade-offs

  • IndexedDB persistence is not shown here to keep the snippet focused, but it is mandatory for CRITICAL priority mutations — an in-memory queue is wiped on page reload.
  • The 500 ms drain delay trades immediate consistency for stability. On flaky connections, the first online event can fire before the radio is fully associated; the delay reduces false-drain failures by roughly 80% in practice.
  • AbortSignal.timeout is available in all modern browsers and eliminates the manual AbortController + setTimeout boilerplate, but requires a fallback (AbortController) for older Safari targets.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Timer count in DevTools grows after each route change refetchInterval implemented via setInterval inside useEffect without a cleanup return, or useQuery called with an interval outside a React tree (e.g. in a service module) Replace manual timers with refetchInterval in useQuery; TanStack Query tears down the scheduler when all observers unmount. Confirm by checking queryClient.getQueryCache().getAll() — queries with observers.length === 0 should stop firing.
Background fetch overwrites post-mutation UI state causing visible flash A poll resolves after the mutation commit, writing older server data back to the cache; structuralSharing does not help because the server payload genuinely differs from the optimistic state Set refetchOnWindowFocus: false inside mutation callbacks and re-enable via queryClient.setQueryDefaults once mutation.isSuccess. Alternatively check lastModified headers before applying the background payload.
429 errors from the API within seconds of reconnecting from offline Offline queue drains synchronously without backoff; all queued mutations fire in a tight loop; multiple browser tabs reconnect simultaneously Add per-request retryDelay with jitter: Math.random() * 2000 + baseDelay. Drain serially (one at a time) rather than via Promise.all. Consider a service worker to coordinate drains across tabs.
navigator.onLine returns true but fetches fail on mobile Device has a link-local connection (Wi-Fi without DNS) or is behind a captive portal; onLine only checks for a network interface, not upstream reachability Replace navigator.onLine checks with the probe-based isOnline() pattern above; cache the probe result for 5–10 s to avoid excessive HEAD requests.

Frequently Asked Questions

How do I prevent background refetches from overwriting in-flight optimistic mutations?

Set refetchOnWindowFocus: false during active mutation states and restore it via queryClient.setQueryDefaults once the mutation settles. In TanStack Query v5, useIsMutating() returns the count of in-flight mutations scoped to a key — pass the result as a condition: refetchOnWindowFocus: isMutating === 0. This integrates cleanly with the Mutation Sync & Rollback patterns that gate rollback on whether a background read has corrupted the optimistic state.

When should I use polling versus WebSockets for background synchronization?

Use polling when: the data update frequency is below 1/min, the server does not support persistent connections, or the cost of maintaining a WebSocket connection outweighs the value of push latency. Use WebSockets when: sub-second latency matters, the server emits delta events (rather than full snapshots), or multiple clients must see the same ordered event sequence. Hybrid architectures poll for the initial hydration snapshot and then switch to WebSocket push for live deltas — the poll covers the gap between page load and socket connection establishment.

How do I stop timer accumulation when routes unmount in React Query?

Do not use setInterval inside useEffect to drive background fetches. Use the refetchInterval option in useQuery instead. TanStack Query registers an internal RefetchIntervalObserver and tears it down automatically when observers.length drops to zero (i.e. all components using that query key have unmounted). You can confirm timers have been cleaned up by checking queryClient.getQueryCache().getAll() and filtering for entries where getObserversCount() === 0 — those entries should not be refetching.

Is navigator.onLine reliable enough to gate background fetches?

No. navigator.onLine returns true whenever the device has any network interface up, including captive portals and link-local addresses with no upstream route. On mobile, it can stay true through the entire duration of a connectivity gap if the Wi-Fi interface remains associated. Supplement it with a lightweight HEAD probe to a stable CDN endpoint (the isOnline() helper above), and cache the probe result for 5–10 s. TanStack Query’s networkMode: 'online' uses navigator.onLine internally — useful as a first gate but not sufficient on its own for production offline handling.


  • Cache Invalidation & Server Synchronization — the parent discipline covering how client caches maintain consistency with server state across the full invalidation lifecycle.
  • Optimizing SWR Revalidation Intervals — a focused recipe for tuning refreshInterval and dedupingInterval in SWR v2 to balance bandwidth against data freshness.
  • Mutation Sync & Rollback — covers how to coordinate background reads with optimistic writes so that a late-arriving poll does not silently overwrite in-progress mutation state.
  • Stale-While-Revalidate Implementation — the conceptual pattern underlying all background refetch: serve stale immediately, replace with fresh silently.
  • Tag-Based Invalidation Systems — explains how to scope cache invalidation to specific entity tags, which determines which queries should poll and which should wait for a targeted invalidation push.