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.

Key dimensions that isolate editorial contextsA key built from collection, slug, locale and state; changing any one dimension produces a distinct cache entry, so drafts and locales never collide.collectionpagesslugpricinglocalede-DEstatepublishedstatedraftpreview
Each colon-separated segment is a dimension; the state segment is what keeps previews out of production caches.

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:

A publish reaching a mounted componentThe CMS publish event passes through an SSE bridge and a debounce queue, which calls mutate with a prefix matcher; SWR refetches in the background and the component re-renders.Headless CMSSSE bridgeDebounce queueSWR cacheComponentpublish (collection, slug, locale)enqueueInvalidationmutate(prefix matcher)background refetchfresh payloadre-render
The debounce queue turns a bulk publish into one mutate per affected key.
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:

Retry delays under the onErrorRetry policyDelay before each retry attempt with linear backoff of one second per attempt, stopping after three attempts, and no retries for 404.Retry 1 (after 5xx)1 sRetry 22 sRetry 33 s404: no retry0 s
Linear backoff keeps the total wait short for content; 404s end immediately because missing content will not reappear on retry.
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.

Worked Example: A Pricing Page That Changes During a Sale

A software company runs its pricing page on Storyblok with SWR on the client. During a launch week, marketing changes the discount banner several times a day, and the page must reflect each change within seconds for visitors who already have it open.

The page is server-rendered with the current banner and passes it as fallbackData under the key cms:pages:pricing:en:published. revalidateOnMount is off because the server data is fresh. The page also opens an EventSource to /api/cms-events. When marketing publishes, Storyblok’s webhook reaches a verified route that broadcasts { collection: "pages", slug: "pricing", locale: "en" }. The client’s debounce queue waits 500 ms, in case marketing publishes the banner and the plan table in quick succession, then calls mutate with the prefix matcher. SWR refetches through the proxy, whose CDN copy was purged by the same webhook, and the banner updates in place without a reload.

Two details made this reliable in practice. The proxy returned Cache-Control: public, s-maxage=30, stale-while-revalidate=120, and the webhook purged the proxy path before broadcasting, so the refetch could not receive the CDN’s stale copy. And the matcher used the full segment prefix cms:pages:pricing:, which avoided accidentally revalidating the pricing-faq page that shares the slug prefix.

The same architecture works for any SWR-backed view that must follow the CMS closely: event schedules, status pages, or a newsroom’s live coverage index. The only parameters that change are the debounce window and the proxy’s edge TTL, which should both shrink as the cost of staleness grows.

Rollout Checklist

  • Build every key with the central buildCacheKey helper, including the state segment.
  • Normalize responses in the fetcher and validate them with a schema.
  • Seed hydration with fallbackData and set revalidateOnMount from its presence.
  • Verify webhooks server-side, purge the proxy path, then broadcast ids over SSE.
  • Debounce invalidations per key and match whole key segments only.
  • Configure onErrorRetry to skip 404s and cap retries.

Configuration Reference

Option Value here Effect
dedupingInterval 2000 ms Collapses duplicate requests from components mounting together.
revalidateOnMount false with fallbackData Trusts server-rendered data on first render.
revalidateOnFocus false Event-driven invalidation replaces focus polling.
revalidateOnReconnect true Catches up after network drops, when events may have been missed.
onErrorRetry max 3, skip 404, linear backoff Bounded retries that never hammer the CMS proxy.
Debounce window 500 ms per key Coalesces bulk publishes into one mutate.

revalidateOnMount: false is only safe when fallbackData is always provided. On client-side navigations without server data, set it back to true, or the hook will render nothing until a trigger fires. A common approach is revalidateOnMount: !serverProps.initialData.

Gotchas & Edge Cases

  • Prefix matchers and similar slugs. startsWith("cms:pages:price") also matches cms:pages:pricing. End every segment with the separator in the matcher (cms:pages:price:), so prefixes match whole segments only.
  • SSE through proxies. Some corporate proxies and older load balancers buffer event streams, which delays events by minutes. Send a comment line every 15 to 25 seconds and set X-Accel-Buffering: no for nginx.
  • Missed events after sleep. A laptop that slept misses events. revalidateOnReconnect covers network drops; for sleep, revalidate active keys on visibilitychange when the tab has been hidden longer than the debounce window.
  • Draft data in fallbackData. If the server rendered a preview, its fallbackData is draft content. Make sure the key used on the client carries the draft state, so the published key never receives it.

Frequently Asked Questions

Why use Server-Sent Events rather than polling for revalidation?

Polling costs a request per tab per interval, whether or not anything changed. SSE costs one idle connection per tab and one small message per publish. For content that changes a few times a day, SSE reduces requests by orders of magnitude and delivers changes faster.

Does mutate with a matcher function revalidate keys that are not mounted?

It marks every matching key in the cache. Mounted keys refetch immediately when revalidate is true; unmounted keys are refetched the next time a component uses them. That avoids fetching data nobody is looking at.

How do I verify the whole path works?

Open the page, publish a change and watch for exactly one SSE message and one fetch per affected key in the Network panel. Automate the same check with Playwright by posting a signed webhook to a staging server and waiting for the new text to appear.

Should the debounce window be the same for every content type?

No. Navigation and settings change rarely and affect every page, so a slightly longer window of one to two seconds absorbs multi-entry releases. Article bodies benefit from the shortest practical window so authors see their fix quickly. Keep the window per key prefix in a small configuration map.