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:
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.
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
buildCacheKeyhelper, including the state segment. - Normalize responses in the fetcher and validate them with a schema.
- Seed hydration with
fallbackDataand setrevalidateOnMountfrom 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
onErrorRetryto 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 matchescms: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: nofor nginx. - Missed events after sleep. A laptop that slept misses events.
revalidateOnReconnectcovers network drops; for sleep, revalidate active keys onvisibilitychangewhen the tab has been hidden longer than the debounce window. - Draft data in fallbackData. If the server rendered a preview, its
fallbackDatais draft content. Make sure the key used on the client carries thedraftstate, 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.