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.
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:
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
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.
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: falsewithstaleTime: 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
staleTimefor secondary widgets. - Invalidate then refetch twice.
invalidateQueriesalready refetches active queries by default. Following it withrefetchQueriesfor the same key, as the blueprint above does for clarity, sends a second request unless you passrefetchType: "none"to the invalidation. Pick one. - CDN caching the proxy. If
/api/cms/*responses carrys-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
staleTimeper 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.