Cache Interaction & Stale-While-Revalidate
Serving fresh content without blocking the user is the central tension in HTTP caching. The stale-while-revalidate extension to Cache-Control (RFC 5861) resolves that tension by decoupling the moment a resource is served from the moment it is validated: the browser hands the cached copy to the page immediately, then dispatches a background fetch to refresh the cache for the next visitor. Understanding how this fits into the broader browser resource priority queue — and where it can go wrong — is what this page covers in full.
What stale-while-revalidate actually does
The HTTP cache layer is a state machine. For every incoming request it evaluates three questions in order: is a stored response available? Is it fresh? If not fresh, is revalidation required before serving? stale-while-revalidate adds a fourth state between “fresh” and “must-revalidate”:
- Fresh (
age < max-age): serve from cache, no network. - Stale-but-revalidatable (
max-age < age < max-age + stale-while-revalidate): serve stale immediately and issue a background conditional GET. - Expired (
age > max-age + stale-while-revalidate): block on a synchronous network fetch before responding.
The background fetch is a conditional request — it sends If-None-Match (ETag) or If-Modified-Since. If the origin responds 304 Not Modified, the cache updates its metadata without a body transfer. If the origin responds 200 OK, the new body replaces the stale entry.
SWR state diagram
Spec-level definition and browser engine differences
stale-while-revalidate is defined in RFC 5861 (2010) and incorporated into the HTTP caching specification (RFC 9111 / Fetch Standard). The directive is a response directive only — it is meaningless in request headers.
Browser engine comparison
| Behaviour | Chromium | WebKit (Safari) | Gecko (Firefox) |
|---|---|---|---|
| SWR in HTTP cache | Full support since Chrome 75 | Partial — honours max-age; SWR treated as 0 in some versions |
Full support since Firefox 132 |
| Background fetch priority | Low / Idle in network scheduler |
Deferred; may be promoted on tab focus | Runs at default Normal unless idle |
| Hidden-tab throttling | Defers SWR fetch; runs on tab activation | Defers; budget applies in some iOS versions | Defers based on timeout budget |
Vary header handling |
Partitions cache by Vary fields | Partitions cache by Vary fields | Partitions cache; broad Vary: * skips SWR |
| Service Worker interaction | SW fetch event fires first; SWR from HTTP cache is skipped if SW responds | Same | Same |
WebKit caveat. Safari’s HTTP cache implementation has historically varied between iOS and macOS versions. Always test SWR behaviour on real Safari builds; the
ageresponse header is the most reliable diagnostic signal.
Spec/API reference: directive fields and browser support matrix
Cache-Control directive syntax (RFC 9111 + RFC 5861)
| Directive | Applies to | Value type | Notes |
|---|---|---|---|
max-age=N |
Response | Seconds (integer) | Primary freshness window. Requests within this range are cache hits with zero network cost. |
stale-while-revalidate=N |
Response | Seconds (integer) | Grace period after max-age. Combined window = max-age + stale-while-revalidate. |
public |
Response | Flag | Allows shared CDN caching. Required for SWR to work at the edge. |
private |
Response | Flag | Restricts to browser cache only; SWR still applies but edge nodes must not cache. |
no-store |
Response | Flag | Disables all caching — SWR is ignored entirely. Use for auth endpoints. |
must-revalidate |
Response | Flag | Disables the stale-serving part of SWR; always blocks on revalidation at max-age. |
stale-if-error=N |
Response | Seconds (integer) | Related: allows stale serving if the origin returns a 5xx error, independent of SWR. |
Minimum viable header for SWR:
# Serve fresh for 5 minutes; serve stale for up to 1 hour while refreshing in background.
# 'public' is required so shared caches (CDN edge nodes) participate.
Cache-Control: public, max-age=300, stale-while-revalidate=3600
Volatility tiers — recommended TTL values
| Asset category | Example | max-age |
stale-while-revalidate |
Notes |
|---|---|---|---|---|
| Immutable static | app.a3f8c.js |
31536000 |
— | Use URL fingerprinting; SWR not needed. |
| Versioned static | styles.css (no hash) |
86400 |
604800 |
Long SWR covers weekend deploys. |
| API feed / catalogue | GET /api/products |
300 |
1800 |
SWR covers the revalidation RTT gracefully. |
| Config / feature flags | GET /api/flags |
60 |
600 |
Narrow SWR; flag changes must propagate quickly. |
| Personalised / session | GET /api/cart |
— | — | Use Cache-Control: private, no-cache or no-store. |
| Real-time data | Scores, prices | — | — | Disable SWR; use no-cache, must-revalidate. |
Step-by-step implementation
Step 1 — Classify every route by volatility
Before writing a single header, group your routes into the tiers above. The wrong tier choice (e.g., applying a 1-hour SWR to a real-time price feed) causes silent data staleness that is difficult to reproduce in testing.
Step 2 — Set headers at the origin
Nginx:
location /api/catalogue {
proxy_pass http://backend;
# 'always' ensures the header is set even on 4xx/5xx,
# so CDN edge nodes know not to cache error responses with SWR.
add_header Cache-Control "public, max-age=300, stale-while-revalidate=1800" always;
proxy_cache_valid 200 300s;
# Bypass cache for requests that explicitly ask for fresh data.
proxy_cache_bypass $http_cache_control;
}
Express / Node.js middleware:
// Apply SWR headers to catalogue routes only.
// Auth and user-scoped routes must never reach this middleware.
app.use('/api/catalogue', (req, res, next) => {
res.set(
'Cache-Control',
'public, max-age=300, stale-while-revalidate=1800'
);
next();
});
Step 3 — Configure CDN edge rules
CDNs often strip stale-while-revalidate from Cache-Control unless you also provide a platform-specific directive. Setting Surrogate-Control or the CDN’s native TTL field is mandatory for SWR to take effect at the edge.
Fastly VCL:
sub vcl_deliver {
# Fastly reads Surrogate-Control for edge TTLs; strip it before the browser sees it.
if (req.url ~ "^/api/catalogue") {
set resp.http.Cache-Control = "public, max-age=300, stale-while-revalidate=1800";
set resp.http.Surrogate-Control = "max-age=300, stale-while-revalidate=1800";
unset resp.http.Surrogate-Control; // Remove from browser-facing response
}
}
Cloudflare (via response header transform rule):
# In Cloudflare Dashboard → Rules → Transform Rules → Modify Response Header
# Match: URI path starts with /api/catalogue
# Action: Set Cache-Control = public, max-age=300, stale-while-revalidate=1800
Always set a
Vary: Accept-Encoding(or the minimum requiredVaryfields). A broadVary: *fragments the cache into per-request entries, preventing SWR from ever serving a matching stale response.
Step 4 — Framework-level alignment
Meta-frameworks abstract cache headers into route or data-fetching config. Misaligning the framework’s regeneration window with the HTTP SWR window creates race conditions where the CDN serves a stale ISR page while the browser’s HTTP cache already has a newer version, or vice versa.
Next.js App Router:
// Route segment config — revalidate every 300 seconds.
// Keep this in sync with max-age=300 in your CDN edge rule.
export const revalidate = 300;
// Or per-fetch, with SWR-equivalent behaviour:
const data = await fetch('/api/catalogue', {
next: { revalidate: 300 }, // ISR window matches max-age
});
React swr library (client-side):
import useSWR from 'swr';
// dedupingInterval mirrors the HTTP max-age window (300 000 ms = 300 s).
// This prevents redundant client-side fetches during the HTTP freshness period.
const { data } = useSWR('/api/catalogue', fetcher, {
dedupingInterval: 300_000,
revalidateOnFocus: false, // avoid re-fetch when user tabs back in
});
The
swrlibrary operates on client-side fetch; it is independent of the HTTPCache-Controllayer. Both layers need to be tuned — the HTTP cache governs network-level deduplication, the library governs in-memory deduplication across components.
Verification workflow
DevTools Network panel — four-state test
Open Chrome DevTools → Network tab. Perform these four requests in sequence, leaving cache enabled throughout:
- Cold (first load):
Status: 200,Size: N KBfrom network. Check response headers forCache-Controlwith expected SWR values. - Within
max-age: Reload within the fresh window.Status: 200,Size: (memory cache)or(disk cache). Zero network bytes transferred. - Within SWR window: Wait past
max-agebut withinmax-age + stale-while-revalidate. Reload. You should see two entries for the same URL: the instant cache hit followed by a second lower-priority background request (often markedLowin the Priority column). Check the background request’s status —304 Not Modifiedmeans ETag matched;200 OKmeans content changed. - Past SWR window: Wait until
age > max-age + stale-while-revalidate. The next request blocks synchronously on the network fetch.TTFBwill spike to the full round-trip time.
Confirm the age response header increments between step 2 and 3 — this is the only reliable cross-browser signal of whether SWR is active.
PerformanceObserver — distinguish cache hits in RUM
// transferSize === 0 with encodedBodySize > 0 means the response came from
// the HTTP cache (memory or disk). transferSize > 0 means a network byte transfer
// occurred — either a full response or a revalidation exchange.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const isCacheHit =
entry.transferSize === 0 && entry.encodedBodySize > 0;
const isRevalidation =
entry.transferSize > 0 && entry.transferSize < entry.encodedBodySize;
console.log(entry.name, {
cacheHit: isCacheHit,
revalidation: isRevalidation,
ttfb: (entry.responseStart - entry.startTime).toFixed(1) + 'ms',
});
}
});
observer.observe({ type: 'resource', buffered: true });
Ship this snippet to your RUM platform (Datadog, New Relic, or a custom pipeline) and alert when cache hit ratio falls below 85% or revalidation TTFB exceeds 500 ms.
Edge cases and gotchas
must-revalidate silently disables SWR
must-revalidate and stale-while-revalidate are mutually exclusive. If both appear in the same Cache-Control header, must-revalidate wins — the browser never enters the stale-serving state. This is a common gotcha when framework defaults inject must-revalidate and origin headers add stale-while-revalidate.
Diagnosis: inspect the final (merged) Cache-Control header in DevTools after any CDN or framework middleware has processed it. The winning directive may not be what the origin sent.
CORS and cross-origin revalidation
The background SWR revalidation fetch is a cross-origin request if the asset lives on a different origin than the page. It must carry the correct Access-Control-Allow-Origin response header. If the CORS preflight fails, the background fetch silently aborts — the cache is never refreshed, and users are stuck with stale content indefinitely until the resource transitions to the Expired state. Detecting this requires monitoring the Network panel for failed background OPTIONS requests.
This CORS constraint interacts directly with preconnect and DNS-prefetch behaviour: a preconnect hint does not inherit CORS credentials, so even a pre-warmed connection may trigger a fresh CORS exchange on the SWR revalidation fetch.
Service Worker intercept precedence
If a Service Worker is registered on the origin, its fetch event fires before the HTTP cache is consulted. A SW that calls event.respondWith(cache.match(request)) will serve its own cached copy, bypassing the HTTP stale-while-revalidate logic entirely. The two caching layers are independent and can produce confusing staleness patterns if not explicitly coordinated. When debugging apparent SWR failures, always check the Application → Service Workers panel and temporarily bypass the SW with Shift+Reload.
Hidden tab throttling extends the effective stale window
All three major engines throttle background network activity in non-visible tabs. A user who leaves a tab open for an hour will not have their SWR background fetch run until they return. The effective stale window therefore exceeds the configured value by the tab-hidden duration. Design stale-while-revalidate values with this in mind: for content where a 60-minute stale period is unacceptable, use a narrower SWR window and accept the increased TTFB on re-activation.
Vary header cache fragmentation
Vary: Accept-Language combined with stale-while-revalidate creates per-locale cache entries. The background revalidation fetch must also include the matching Accept-Language value for the cache update to apply to the same entry — otherwise the browser revalidates one locale entry while the user is served a different locale’s stale response. Keep Vary fields to the minimum required set. Avoid Vary: *; it prevents all cache matching and makes SWR non-functional.
Interaction with render-blocking resource identification
SWR background fetches are scheduled at Low or Idle network priority in Chromium. This is the correct behaviour for non-critical resources, but it creates a subtle interaction if those resources are actually render-critical (for example, a CSS file loaded without rel="preload" that happens to be within its SWR window). The browser will serve the stale CSS immediately — which avoids blocking render — but the background revalidation fetch will run at low priority, meaning updates take longer to propagate than if the resource had been treated as critical. If you are applying SWR to CSS or fonts, ensure the resource is also preloaded so that post-SWR fetches inherit the correct priority.
FAQ
Does stale-while-revalidate affect Core Web Vitals?
SWR reduces TTFB variance by serving the cached copy instantly, which stabilises LCP timing. The background revalidation runs at Low or Idle priority and does not block the main thread, so it has negligible impact on INP or CLS. The risk is the opposite: if SWR serves a stale critical resource (a hero image or above-the-fold CSS) whose content has changed, the update only appears on the next navigation, not the current one.
Will CDNs strip stale-while-revalidate?
Some CDNs strip or ignore SWR unless you also provide a platform-specific directive. Fastly requires Surrogate-Control; Cloudflare honours Cache-Control but benefits from explicit edge TTL rules. Akamai’s SureRoute configuration may also require the directive to be declared in cache rules rather than origin headers. Always verify the directive reaches the browser by inspecting the age header on a second request.
Can I use stale-while-revalidate with personalised responses?
No. Personalised or session-scoped responses must carry Cache-Control: private, no-store. SWR is designed for shared public caches; applying it to user-specific data risks a CDN edge node serving one user’s response to a different user if the Vary header is not configured correctly.
What happens when the SWR grace period expires before the background fetch completes?
If the background fetch has not resolved by the time max-age + stale-while-revalidate has elapsed, the cache entry transitions to Expired. The next request blocks on a synchronous network fetch, temporarily spiking TTFB. This situation arises when origin latency is high relative to the SWR window. Mitigate it by either widening the SWR window or reducing origin latency through connection optimisation — see HTTP/2 and HTTP/3 multiplexing for techniques to reduce round-trip cost.
How does SWR behave in hidden browser tabs?
All three major browser engines throttle background network activity in non-visible tabs. SWR revalidation fetches are deferred until the tab becomes active, extending the effective stale window beyond the configured value. This is particularly visible in mobile Safari on iOS, where aggressive background tab suspension can delay SWR fetches by many minutes.
Related
- Cache-Control headers vs resource hints — distinguishing caching directives from preloading signals
- Understanding browser resource priority queues — how Chromium assigns fetch priority tiers
- Render-blocking resource identification — diagnosing resources that delay First Contentful Paint
- Network waterfall anatomy & timing metrics — reading TTFB, stall, and download timing in DevTools
- Core Browser Loading Mechanics & Priority Queues ↑ parent section