Implementing SWR cache revalidation for dynamic content
SWR resolves the freshness-versus-latency tradeoff by returning cached data immediately and refetching in the background — but for headless CMS content it only works with deterministic cache keys, event-driven invalidation, and explicit handling of hydration and network failures. This guide gives the exact SWR Stale-While-Revalidate Patterns configuration that aligns client caching with the Data Fetching & Caching Strategies behind a scalable headless deployment.
Step 1: Deterministic cache keys
Problem: raw URL strings or loose identifiers cause cross-locale data bleeding and draft/production collisions. SWR’s default string-based key comparison can’t isolate editorial contexts.
Fix: a hierarchical key schema encoding every content dimension.
const buildCacheKey = (collection, slug, locale = 'en', state = 'published') =>
`cms:${collection}:${slug}:${locale}:${state}`;
Enforce typing at the TypeScript level, and append a preview or draft state suffix so editorial workflows never pollute production caches.
Prevention: derive all keys from a central factory. Audit useSWR calls and reject dynamic string concatenation in render cycles.
Step 2: Normalize responses and set revalidation windows
Problem: CMS APIs wrap payloads in metadata layers ({ data: { attributes: {...} } }) that shift between draft and published. Inconsistent shapes break cache hashing, trigger re-renders, and corrupt fallbackData hydration.
Fix: normalize inside the fetcher before the cache.
const fetchCMSData = async (key) => {
const res = await fetch(`/api/cms/${key}`);
if (!res.ok) throw new Error('CMS fetch failed');
const json = await res.json();
// Normalize to a predictable shape
return {
id: json.data.id,
content: json.data.attributes,
meta: json.meta,
fetchedAt: Date.now()
};
};
Then suppress redundant calls during hydration and rapid interaction:
const options = {
dedupingInterval: 2000, // Collapse duplicate requests within 2s
revalidateOnMount: false, // Trust SSR/SSG pre-fetched data
revalidateOnFocus: false, // Disable when using event-driven updates
revalidateOnReconnect: true // Recover from mobile network drops
};
Prevention: validate fetcher output with Zod or JSON Schema, log shape mismatches in dev to catch CMS API drift, and document the normalized contract.
Step 3: Bridge webhooks to mutate()
Problem: refreshInterval or focus-based revalidation adds latency gaps that frustrate editors expecting instant updates, and polling wastes bandwidth.
Fix: open a Server-Sent Events connection for publish events (MDN: Server-Sent Events) and map payloads to active keys with a matcher.
A publish travels from the CMS to the mounted component over this path:
sequenceDiagram participant CMS as Headless CMS participant SSE as SSE / webhook bridge participant Q as Debounce queue participant SWR as SWR cache participant UI as Component CMS->>SSE: publish event (collection, slug, locale) SSE->>Q: enqueueInvalidation (coalesce bulk) Q->>SWR: mutate(prefix matcher, revalidate: true) SWR->>CMS: background refetch CMS-->>SWR: fresh payload SWR-->>UI: re-render with new data
import { mutate } from 'swr';
function handleCMSUpdate({ collection, slug, locale }) {
const prefix = `cms:${collection}:${slug}:${locale}`;
mutate(
(key) => typeof key === 'string' && key.startsWith(prefix),
undefined,
{ revalidate: true }
);
}
Debounce to coalesce bulk publishes:
const debounceQueue = new Map();
function enqueueInvalidation(payload) {
const key = `${payload.collection}:${payload.slug}`;
if (debounceQueue.has(key)) clearTimeout(debounceQueue.get(key));
debounceQueue.set(key, setTimeout(() => {
handleCMSUpdate(payload);
debounceQueue.delete(key);
}, 500));
}
Prevention: verify webhook signatures server-side, back off SSE reconnections to avoid connection storms during maintenance, and monitor mutate() frequency to catch invalidation thrashing.
Step 4: Hydration mismatches and network failures
Problem: server-rendered HTML diverges from the client cache during hydration, causing React warnings and layout shifts. Failed background revalidations degrade freshness silently.
Fix: pass server data to fallbackData for synchronous hydration.
const { data, error, isValidating } = useSWR(
cacheKey,
fetchCMSData,
{ fallbackData: serverProps.initialData }
);
Handle revalidation failures with onErrorRetry:
const options = {
...previousOptions,
onErrorRetry: (error, key, config, revalidate, { retryCount }) => {
if (retryCount >= 3) return; // Stop after 3 attempts
if (error.status === 404) return; // Don't retry missing content
setTimeout(() => revalidate({ retryCount }), 1000 * retryCount);
}
};
Render a loading state that respects isValidating without blocking the UI. The SWR revalidation docs cover advanced retry config.
Prevention: run hydration tests in CI with Playwright, monitor isValidating and error in production telemetry, and surface a banner when error persists past the retry limit.
Validation checklist
Deterministic keys, normalized fetcher output, and publish events wired straight to cache invalidation are what let SWR deliver sub-second interactions while keeping editorial and production content accurate.