React Query Background Refetch Strategies for CMS

Part of React Query for CMS Data, this guide starts from the fact that CMS content follows a write-rare, read-heavy pattern, so React Query’s default refetch behavior — refetch on every mount and focus — wastes bandwidth, bypasses the CDN cache, and causes UI jitter on route transitions. This page tunes staleTime, gcTime, and event-driven invalidation to that access pattern, so published changes surface fast without polling the origin into the ground.

How background refetch works

React Query transitions a cached query from fresh to stale, then fires a silent request on the next relevant lifecycle event. In a CMS context the calibration is deliberate: aggressive defaults waste bandwidth and force CDN cache bypasses. The goal is to reflect an editor’s publish or field change without full page reloads or unsustainable polling — serve cached content instantly, refetch asynchronously. This is the Data Fetching & Caching Strategies priority of deterministic cache states and perceived performance over naive real-time sync.

Tuning for CMS data

staleTime by content volatility

staleTime is how long a query stays fresh before it’s eligible for a background refetch. The default staleTime: 0 refetches on every mount and focus — wrong for editorial content that’s static for hours. Tier it by volatility:

  • Static pages, global navigation: staleTime: 1000 * 60 * 30 (30 min)
  • Blog posts, marketing articles: staleTime: 1000 * 60 * 5 (5 min)
  • Dynamic feeds, pricing, comments: staleTime: 1000 * 60 (1 min)

Pair staleTime with a longer gcTime (garbage collection, formerly cacheTime) to avoid premature eviction during client-side routing. Retaining the cache for 10–15 minutes gives instant hydration on return visits while background updates still propagate, minimizing layout shift and preserving scroll position.

staleTime and gcTime for a blog post queryA blog post query stays fresh for five minutes, is stale but cached until the last observer unmounts, and is garbage-collected fifteen minutes later.fresh (staleTime 5 min)no refetchstale, observedserved, refetch on triggerinactive, cachedgcTime 15 min0 min5 min10 min15 min20 min25 min30 minlast unmount
Fresh data is served with no request; stale data is served instantly and refetched on the next trigger; gcTime only starts once nothing observes the query.

Event-driven invalidation over polling

refetchInterval polling is justified only when the CMS has no webhooks or when collaborative editing needs sub-second sync. Otherwise event-driven invalidation wins — no redundant requests. When polling is unavoidable, set refetchIntervalInBackground: false so background tabs don’t exhaust the CMS rate limit.

For event-driven flows, build a centralized invalidation layer subscribed to CMS webhooks or Server-Sent Events. Refetches fire only on actual mutations, preserving client resources and API quota. This depends on hierarchical, predictable query keys for targeted invalidation — see React Query for CMS Data.

A predictable invalidation layer

Decouple the CMS event stream from the component tree. Instead of scattering invalidateQueries across components, run a synchronization service that maps webhook payloads to query keys by mutation type:

  1. Full document replacement: invalidate the exact key (['cms', 'page', slug]) and background-refetch.
  2. Partial field update: setQueryData for an optimistic patch, then refetchQueries to reconcile with the source.
  3. Collection mutation (new post): invalidate the list key (['cms', 'posts', 'list']) while keeping individual post caches.

The sync service branches on mutation type so each webhook touches only the keys it affects:

Sync service branches on mutation typeA CMS webhook or SSE event is classified as a full document change, a partial field update or a collection change, and each class touches a different set of query keys.Webhook orSSE eventMutationtypeinvalidate[cms, page, slug]setQueryData patchthen refetchinvalidate[cms, posts, list]documentfieldcollection
Each event touches only the keys it affects; individual post caches survive a collection change.

This eliminates race conditions and runs each refetch exactly once per mutation. The TanStack Query docs cover invalidation and deduplication semantics in depth.

Production blueprint

A consistent wrapper around useQuery enforces CMS-specific defaults:

TypeScript
import { useQuery, QueryClient, useQueryClient } from '@tanstack/react-query';

// CMS-aware query defaults
const cmsQueryDefaults = {
  staleTime: 1000 * 60 * 5,
  gcTime: 1000 * 60 * 15,
  refetchOnWindowFocus: false,
  refetchOnMount: false,
  retry: 2,
};

export const useCmsQuery = <T>(key: string[], fetcher: () => Promise<T>) => {
  return useQuery({
    queryKey: ['cms', ...key],
    queryFn: fetcher,
    ...cmsQueryDefaults,
  });
};

// Centralized invalidation service
export const invalidateCmsContent = async (
  client: QueryClient,
  eventType: 'document' | 'collection',
  identifier: string
) => {
  const queryKey = eventType === 'document'
    ? ['cms', 'page', identifier]
    : ['cms', 'posts', 'list'];

  await client.invalidateQueries({ queryKey });
  await client.refetchQueries({ queryKey, type: 'active' });
};

Render cached data immediately and show a progress indicator only when isFetching && !isLoading — that separates a background refetch from an initial load and prevents the flash of empty state.

Network awareness, observability, testing

React Query pauses refetches when the tab loses focus or the device goes offline. But a tab left open for hours can go stale; wire the Page Visibility API to trigger a soft refetch only when the tab becomes visible.

Background refetches usually bypass the browser cache but may still hit a CDN edge node. Set CMS responses to Cache-Control: public, max-age=300, stale-while-revalidate=600 aligned with your staleTime. Misaligned CDN TTLs and React Query staleness windows produce either duplicate fetches or delayed updates.

CMS requests per hour for 1,000 open tabsRequests reaching the CMS proxy in one hour from 1,000 open tabs under four refetch strategies, with three publishes in that hour.refetchInterval 30 s120000 req/hrefetchInterval 5 min12000 req/hFocus refetch, staleTime 5 min4000 req/hSSE invalidation3000 req/hFocus estimate assumes four focus events per tab per hour after the data went stale.
Modelled for 1,000 tabs, one watched query each, three publishes per hour; event-driven invalidation costs one request per tab per publish.

Automated testing for headless integrations should verify refetch boundaries. Use @testing-library/react with msw to simulate:

  • Initial cache population
  • Background refetch after staleTime expiration
  • Webhook-driven invalidation and UI reconciliation
  • Network failure recovery with exponential backoff

Visibility-Aware Refetching

React Query’s focus refetching listens to the visibilitychange event through its focusManager, so a tab that becomes visible refetches its stale active queries. That is exactly what long-lived reading sessions need, but two refinements make it cheaper for CMS content. First, throttle: a reader who switches tabs every few seconds should not trigger a refetch each time. Second, prioritize: only the queries rendered above the fold need to refetch immediately.

TypeScript
import { focusManager, onlineManager } from "@tanstack/react-query";

// Refetch on visibility at most once per minute.
let lastFocusRefetch = 0;
focusManager.setEventListener((handleFocus) => {
  const onVisibility = (): void => {
    if (document.visibilityState !== "visible") return handleFocus(false);
    const now = Date.now();
    if (now - lastFocusRefetch < 60_000) return;
    lastFocusRefetch = now;
    handleFocus(true);
  };
  document.addEventListener("visibilitychange", onVisibility);
  return () => document.removeEventListener("visibilitychange", onVisibility);
});

// Treat captive portals and flaky mobile networks as offline until a request succeeds.
onlineManager.setEventListener((setOnline) => {
  const update = (): void => setOnline(navigator.onLine);
  window.addEventListener("online", update);
  window.addEventListener("offline", update);
  return () => {
    window.removeEventListener("online", update);
    window.removeEventListener("offline", update);
  };
});

Secondary widgets such as related posts or a newsletter block can opt out of focus refetching per query with refetchOnWindowFocus: false, leaving the budget for the main content. Together with a sensible staleTime, this produces at most one refetch per minute per tab when a reader returns, and none at all when the data is still fresh.

Configuration Reference

Option CMS default Why
staleTime 5 min (tiered by type) Mount and focus refetches are skipped while data is fresh.
gcTime 15 min Instant back-navigation; unrelated to freshness.
refetchOnWindowFocus false, or true with a long staleTime Focus refetch is cheap only when most focuses find fresh data.
refetchOnMount false for static content Avoids a request on every route transition.
refetchInterval off; 30_000 only during live editing Polling scales with open tabs, not with publishes.
refetchIntervalInBackground false Hidden tabs should not poll.
networkMode "online" Pauses queries while offline instead of failing them.

Gotchas & Edge Cases

  • refetchOnMount: false with staleTime: 0. The combination means stale data is never refetched on mount, and a component can show week-old content if nothing else triggers a refetch. Pair it with webhook invalidation, or keep mount refetching for volatile types.
  • Focus storms. Switching back to a dashboard with forty queries triggers forty requests at once if all are stale. Group related data into fewer queries, or stagger with a longer staleTime for secondary widgets.
  • Invalidate then refetch twice. invalidateQueries already refetches active queries by default. Following it with refetchQueries for the same key, as the blueprint above does for clarity, sends a second request unless you pass refetchType: "none" to the invalidation. Pick one.
  • CDN caching the proxy. If /api/cms/* responses carry s-maxage=300, a background refetch right after a publish can return the old edge copy. Purge the proxy path on publish, or give it a short edge TTL.

Rollout Checklist

  • Set staleTime per content type through one wrapper hook, never per component.
  • Replace polling with webhook-driven invalidation, keeping polling only for live preview.
  • Throttle focus refetching and opt secondary widgets out of it.
  • Align the CMS proxy’s edge TTL with staleTime, and purge it on publish.
  • Test the refetch boundaries with fake timers so the expected refetches happen on schedule.

Frequently Asked Questions

Is polling ever the right choice for CMS content?

Yes: during a live editing session, when an editor watches a preview and the CMS offers no live preview SDK or webhook for draft saves. Enable a short refetchInterval only on preview queries and only while the preview is open. Published pages should never poll.

Why does my component flicker to a loading state on focus?

Background refetches never set isLoading once data exists; they set isFetching. If you see a loading state, the component probably renders the spinner on isFetching, or the query key changed so a new query started with no data. Show spinners only when isPending is true.

How do SSE invalidations behave when a tab was offline?

EventSource reconnects automatically, but events sent while the tab was offline are lost unless the server replays them using the Last-Event-ID header. After a reconnect, a cheap safety net is to invalidate every CMS query once, which React Query turns into refetches only for queries that are active.