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.

JavaScript
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.

JavaScript
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:

JavaScript
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
JavaScript
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:

JavaScript
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.

JavaScript
const { data, error, isValidating } = useSWR(
  cacheKey,
  fetchCMSData,
  { fallbackData: serverProps.initialData }
);

Handle revalidation failures with onErrorRetry:

JavaScript
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.