SWR Stale-While-Revalidate Patterns
Stale-while-revalidate serves cached content immediately, then refreshes it in the background — bridging instant UI response with eventual consistency. Codified for HTTP caching in RFC 5861 and adapted to frontend state management, SWR solves a core headless CMS problem: show content now, fetch the fresh payload after. Within the Data Fetching & Caching Strategies section, it’s the cache-first baseline that minimizes layout shift and perceived latency. This topic covers the pattern at both levels where headless sites use it: the stale-while-revalidate HTTP directive at the CDN, and the SWR library in the browser.
Integration Contract
Stale-while-revalidate is a contract between a cache and a reader: the cache may answer from a copy past its freshness lifetime, as long as it starts fetching a fresh copy at the same time. In a headless stack, that contract appears twice and the two copies must agree.
At the HTTP layer, the CMS proxy or the rendered page sends Cache-Control: public, s-maxage=60, stale-while-revalidate=300. A CDN that supports the directive serves the cached response for 60 seconds, then for up to 300 more seconds serves the stale copy while it refetches in the background. At the application layer, the SWR library does the same in browser memory: useSWR(key, fetcher) returns cached data at once and revalidates according to its triggers.
The auth model follows from where the fetcher runs. SWR hooks run in the browser, so their fetcher must call a same-origin proxy route that holds the CMS token; a token read from a NEXT_PUBLIC_ variable is visible to everyone. The proxy also sets the HTTP caching headers, which lets the CDN absorb repeated revalidations from many tabs.
# .env: client code only knows the proxy path
CMS_API_URL=https://api.example-cms.com/v1
CMS_DELIVERY_TOKEN=published_read_only
CMS_PREVIEW_TOKEN=drafts_server_only
NEXT_PUBLIC_CMS_PROXY=/api/cms
CMS_PROXY_CACHE_CONTROL="public, s-maxage=60, stale-while-revalidate=300"
The lifecycle
SWR runs a three-phase cycle suited to edge-rendered content:
- Serve stale: return cached data immediately on mount, hydration, or route transition.
- Revalidate: fire an async fetch to the CMS API or upstream cache.
- Update: replace the stale payload with fresh data via targeted reconciliation, no full reload.
The three phases run in this order on every read:
This fits content-heavy apps where editorial updates are asynchronous and users rarely need sub-second freshness. Decoupling render from network latency lets editors publish without breaking the frontend performance budget.
Implementation
A production SWR integration needs standardized fetchers, deterministic keys, and centralized config. This blueprint is framework-agnostic (React, Vue, Svelte).
// lib/cms-fetcher.js
// Standardized fetcher with CMS auth, error normalization, and JSON parsing
export const cmsFetcher = async (url) => {
// url is a same-origin proxy path such as /api/cms/pages/about?locale=en;
// the proxy adds the CMS token server-side and sets Cache-Control.
const res = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!res.ok) {
const err = new Error(`CMS Fetch Failed: ${res.status}`);
err.status = res.status;
err.info = await res.json().catch(() => null);
throw err;
}
return res.json();
};
// lib/swr-config.js
// Centralized configuration for predictable cache behavior
export const swrConfig = {
revalidateOnFocus: false,
revalidateOnMount: true,
dedupingInterval: 5000,
errorRetryCount: 2,
errorRetryInterval: 1000,
keepPreviousData: true,
suspense: false
};
Cache keys
Keys must be deterministic and content-addressable. Don’t put timestamps or random IDs in a key unless you explicitly want cache busting.
// ✅ Deterministic
const cacheKey = `/api/cms/pages/${slug}?locale=${locale}&depth=3`;
// ❌ Non-deterministic (breaks cache sharing)
const badKey = `/api/cms/pages/${slug}?t=${Date.now()}`;
The client library and the CDN directive compose. When SWR revalidates, its request goes to the proxy, and the CDN answers from its own stale-while-revalidate window. A publish therefore has to clear both: the CDN copy through a purge, and the browser copy through mutate. Otherwise the browser revalidates promptly and receives the CDN’s stale copy.
The proxy that sets both caches
The proxy route is where the HTTP half of the pattern is configured. It fetches from the CMS with the server-held token, strips volatile fields, and returns the payload with a Cache-Control header that lets the CDN serve stale copies while revalidating. Preview requests get private, no-store so drafts never enter a shared cache.
// app/api/cms/[...path]/route.ts
export async function GET(req: Request, { params }: { params: Promise<{ path: string[] }> }): Promise<Response> {
const { path } = await params;
const url = new URL(req.url);
const preview = url.searchParams.get("preview") === "1";
url.searchParams.delete("preview");
const upstream = await fetch(`${process.env.CMS_API_URL}/${path.join("/")}?${url.searchParams.toString()}`, {
headers: { Authorization: `Bearer ${preview ? process.env.CMS_PREVIEW_TOKEN : process.env.CMS_DELIVERY_TOKEN}` },
cache: "no-store",
});
if (!upstream.ok) {
return Response.json({ message: "CMS unavailable" }, { status: upstream.status === 404 ? 404 : 502 });
}
const body = (await upstream.json()) as Record<string, unknown>;
delete body.requestId; // volatile metadata defeats SWR's equality check
return Response.json(body, {
headers: {
"Cache-Control": preview ? "private, no-store" : process.env.CMS_PROXY_CACHE_CONTROL ?? "public, s-maxage=60, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
}
Authorizing preview requests is left out for brevity. In production, check the draft-mode cookie or an editor session before honouring preview=1, or anyone can read drafts through the proxy.
Revalidation triggers compared
SWR revalidates on several events, and each maps to a different reader behaviour. Choosing the right subset for CMS content is most of the tuning work:
| Trigger | Fires when | For published content | For preview |
|---|---|---|---|
| Mount | a component using the key mounts | on, unless server data seeds the cache | on |
| Focus | the tab becomes visible again | off, or throttled to minutes | on |
| Reconnect | the network comes back | on | on |
| Interval | a timer elapses | off | 2 to 5 s while editing |
mutate |
code calls it, for example from a webhook bridge | the main freshness path | from live-preview events |
The pattern that emerges is that published pages rely on mutate driven by publish events, with reconnect as a safety net, while preview pages revalidate eagerly because editors are watching.
Configuration tradeoffs
| Flag | Behavior | Tradeoff | Use case |
|---|---|---|---|
revalidateOnFocus |
Refetches on window focus | More API calls; UI flash | Admin dashboards, preview, collaborative editing |
refreshInterval |
Polls at a fixed interval | Predictable overhead; stale gaps | Live blogs, editorial preview, real-time feeds |
dedupingInterval |
Drops duplicate requests in a window | Slightly delayed fresh data on concurrent mounts | Concurrent-request scenarios |
keepPreviousData |
Retains stale data during revalidation | No loading state, but shows old data briefly | Pagination, infinite scroll, lists |
focusThrottleInterval |
Throttles focus-triggered revalidations | Less network noise on rapid tab switching | High-traffic public sites, mobile |
Full options in the SWR configuration docs.
Cache management and integration
Programmatic revalidation
When a CMS webhook fires, target the specific key with mutate instead of reloading:
import useSWR, { mutate } from 'swr';
// Triggered by CMS webhook payload
export async function invalidateCMSContent(slug) {
const key = `/api/cms/pages/${slug}?locale=en`;
await mutate(key, undefined, { revalidate: true });
}
For full webhook-to-cache workflows, see Implementing SWR cache revalidation for dynamic content.
When to choose SWR
SWR fits REST or simplified GraphQL endpoints where normalization happens upstream. For highly relational graphs needing query batching and field-level normalization, teams move to React Query for CMS Data or Apollo Client GraphQL Caching. SWR stays optimal when:
- Payloads are flat or pre-flattened at the edge
- Bundle-size limits rule out a heavy GraphQL client
- Cache-first hydration matters more than complex query composition
Aligning with other layers
SWR runs at the client but must align upstream:
- Next.js ISR Implementation: pair
revalidateheaders with SWR polling so static generation and client freshness windows agree. - Content Delivery Network Routing Logic: match the CDN’s
stale-while-revalidateto SWR’sdedupingIntervalto avoid hammering the origin during spikes. - Automated Testing for Headless Integrations: mock
fetchand assert stale-to-fresh transitions under network latency.
Deduplication tuning for multi-island layouts is covered in SWR deduplication for concurrent headless requests.
Schema & Content Modeling Considerations
SWR compares the new response with the cached one using a deep equality check by default, and only re-renders when they differ. Two content modeling habits break that optimization. Responses that carry volatile metadata, such as a fetchedAt timestamp, a request id or a CMS sys.version that bumps on every save, always differ. Every revalidation then re-renders, even when the visible content did not change. Strip volatile fields in the proxy or the fetcher, or pass a custom compare function that ignores them.
Deep references cost more with SWR than with a normalized cache, because SWR stores each key’s payload independently. A navigation menu embedded in every page response is cached once per page. Fetch shared structures such as navigation, footer and author profiles with their own keys, so they are cached once and revalidated once, and keep page payloads to page-specific fields. For locale handling, include the locale in the key string and in the proxy’s cache key, and resolve fallback chains in the proxy so the client never merges locales itself.
Preview & Draft Workflow
Preview is where SWR’s defaults need the most changes. Editors expect every save to appear, so preview keys should revalidate aggressively: refreshInterval of two to five seconds while the preview is open, or better, a live-preview event that calls mutate directly. The preview state must be part of the key, for example a preview=1 parameter on the proxy path, and the proxy must use the preview token and send Cache-Control: private, no-store so drafts never reach the CDN. The broader draft/publish state rules apply unchanged.
Use a separate SWRConfig provider for preview routes rather than toggling options per hook. The preview provider sets refreshInterval, revalidateOnFocus: true and a use middleware that appends the preview flag to every key. Published routes keep conservative defaults, and no hook can accidentally mix the two.
Error Handling & Resilience
SWR keeps the last good data when a revalidation fails, and exposes the failure through error. For CMS content that is almost always the right behaviour: render the cached article and show a quiet notice, instead of replacing it with an error page. Distinguish errors by status in onErrorRetry: stop retrying on 404 and other 4xx responses, back off exponentially on 429 and 5xx, and honour a Retry-After header when the proxy forwards one.
A circuit breaker belongs in the proxy, not in each hook. When the CMS is failing, the proxy can serve its own last good response from a server-side cache with a Warning-style header, and SWR sees a successful, slightly stale response. Readers see content and the CMS gets time to recover, instead of a retry wave from every open tab.
Testing & Observability
SWR hooks test cleanly when each test wraps the component in <SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>. A fresh cache per test prevents state leaking between tests, and a zero deduping interval lets tests trigger revalidation immediately. Mock the proxy with Mock Service Worker so the fetcher runs unchanged. The automated testing for headless integrations topic covers recorded CMS fixtures that keep these mocks faithful to the real API.
For production telemetry, a small use middleware can time every fetch and report key prefix, duration, cache status from the proxy’s response headers and outcome. Grouping by key prefix shows which content types dominate traffic and which need a longer deduping interval.
Aligning client and CDN windows
The client and CDN windows interact in ways that are easy to get backwards. If SWR revalidates more often than the CDN’s s-maxage, most client revalidations receive the CDN’s cached copy and change nothing, so they cost bandwidth without adding freshness. If the CDN window is much longer than the client’s, a publish purged from the CDN but not signalled to the client stays invisible until the next client trigger. The robust arrangement is to make publish events the only freshness path for both: the webhook purges the CDN copy and then broadcasts to clients, which call mutate. The windows then only bound staleness when events go missing, and they can be generous: a minute at the edge with several minutes of stale-while-revalidate, and client revalidation limited to mount and reconnect.
Choosing SWR for a Headless Project
SWR’s strengths are its small bundle, its simple string-key model and its close fit with the HTTP caching semantics that CDNs already implement. It is at its best on content sites where most data is read-only, pages fetch a handful of independent resources, and freshness comes from publish events. Its model is also easy to explain to a team: one key, one cached value, revalidated on a few well-defined triggers.
It is weaker where content is highly relational. SWR does not normalize, so an author shown on forty cards is cached forty times, and updating it means revalidating forty keys or one list key. Apollo’s normalized cache handles that automatically. React Query sits between the two, with richer invalidation hierarchies and mutation tooling but no normalization either. A practical rule: pick SWR when payloads are page-shaped and published through webhooks, pick React Query when you need structured invalidation and mutations, and pick Apollo when the CMS is GraphQL-first and entities repeat across many views.
Deployment checklist
Frequently Asked Questions
What is the difference between SWR the library and stale-while-revalidate the header?
The header (Cache-Control: stale-while-revalidate=N) tells shared caches such as CDNs that they may serve a stale response while refetching. The SWR library applies the same idea to data in browser memory. A headless site usually uses both, and they must be invalidated separately on publish.
Should revalidateOnFocus be on or off for CMS content?
Off for published reading pages, where content rarely changes during a visit and focus events are frequent. On for preview and dashboards, where readers expect current data when they return to the tab. Use focusThrottleInterval if you enable it on public pages.
How does SWR handle two components requesting the same key?
It deduplicates: requests for the same key within dedupingInterval share one fetch, and both components receive the same data. The deduplication guide covers island architectures where each island has its own cache.
Can SWR replace ISR for CMS pages?
No. SWR runs after the page has loaded, so search engines and first paint still need server-rendered content. Use ISR or SSR for the initial HTML and SWR for keeping interactive or long-lived views current.
How long should dedupingInterval be for CMS content?
Between two and five seconds for most sites. It only needs to cover components that mount together during one render or navigation. Longer windows reduce requests slightly but delay revalidation after a mutate from a component that mounted within the window, which can make a publish look slow.
What happens to SWR data when the reader goes offline?
The cache keeps serving what it has, and revalidation pauses until the browser reports it is online again, at which point revalidateOnReconnect refetches active keys. For offline reading beyond the session, persist selected keys through a custom cache provider backed by IndexedDB, and exclude preview keys from persistence.
Is it safe to share one SWR cache between published and preview views?
Only if preview state is part of every key, so the two never collide. A separate provider for preview routes is safer, because it also lets preview use different revalidation options without any risk of them applying to published pages.