React Query Background Refetch Strategies for CMS
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.
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:
- Full document replacement: invalidate the exact key (
['cms', 'page', slug]) and background-refetch. - Partial field update:
setQueryDatafor an optimistic patch, thenrefetchQueriesto reconcile with the source. - 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:
flowchart TD
A["CMS webhook / SSE event"] --> B{"Mutation type"}
B -->|"full document"| C["invalidateQueries: cms/page/slug"]
C --> D["Background refetch"]
B -->|"partial field"| E["setQueryData optimistic patch"]
E --> F["refetchQueries to reconcile"]
B -->|"new post in collection"| G["invalidateQueries: cms/posts/list"]
G --> H["Keep individual post caches intact"]
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:
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.
Automated testing for headless integrations should verify refetch boundaries. Use @testing-library/react with msw to simulate:
- Initial cache population
- Background refetch after
staleTimeexpiration - Webhook-driven invalidation and UI reconciliation
- Network failure recovery with exponential backoff