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

stale-while-revalidate State Machine State diagram showing the three HTTP cache states (Fresh, Stale-SWR, Expired) and the transitions between them based on age versus max-age and the SWR grace window. Fresh age < max-age serve from cache Stale (SWR window) age < max-age + swr serve stale + bg fetch updates cache silently Expired age > max-age + swr block on network max-age expires swr window ends bg fetch → 304 / 200 → back to Fresh full revalidation → 304/200 → Fresh

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 age response 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
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 required Vary fields). A broad Vary: * 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 swr library operates on client-side fetch; it is independent of the HTTP Cache-Control layer. 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:

  1. Cold (first load): Status: 200, Size: N KB from network. Check response headers for Cache-Control with expected SWR values.
  2. Within max-age: Reload within the fresh window. Status: 200, Size: (memory cache) or (disk cache). Zero network bytes transferred.
  3. Within SWR window: Wait past max-age but within max-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 marked Low in the Priority column). Check the background request’s status — 304 Not Modified means ETag matched; 200 OK means content changed.
  4. Past SWR window: Wait until age > max-age + stale-while-revalidate. The next request blocks synchronously on the network fetch. TTFB will 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.