stale-while-revalidate for media assets
stale-while-revalidate (SWR) is the directive that lets a CDN hand back a cached image instantly the moment it expires, then quietly fetch a fresh copy in the background — so no user ever waits on a revalidation round trip. Used well, it removes the latency cliff that mutable media hits at every max-age boundary. Used badly, it serves stale variants for far too long or gets bolted onto assets that never change. This guide, part of Cache-Control Headers for Image and Video Assets within Core Media Fundamentals & Next-Gen Formats, shows exactly when and how to apply SWR (and its sibling stale-if-error) to images, poster frames, and manifests.
What stale-while-revalidate actually does
Under a plain max-age=300, the first request after the 300-second window expires blocks: the cache must revalidate against the origin before it can serve anything, adding a full round trip to that unlucky user’s response time. stale-while-revalidate=N changes this. For N seconds past expiry, the cache serves the stale copy immediately and kicks off an asynchronous revalidation. The waiting user gets a cache-speed response; the next user gets the freshened copy.
Cache-Control: public, max-age=300, stale-while-revalidate=600
│ │
fresh for 300s ─────────┘ │
then stale-but-served-instantly for +600s ───────┘ (background refresh runs)
after 900s total: next request blocks on revalidation
The timeline below shows the three windows a single cached object passes through under max-age=300, stale-while-revalidate=600, stale-if-error=86400, and what the cache does in each:
Warning: SWR only helps assets whose content can change at the same URL. On a content-hashed, versioned URL the bytes never change, so you want immutable — not SWR. Adding stale-while-revalidate to an immutable, fingerprinted asset is pointless: there is nothing to revalidate, and the directive just adds noise to the header.
Prerequisite checklist
How the major CDNs support SWR
| CDN | SWR honoured at edge | Notes |
|---|---|---|
| Cloudflare | Yes | Respects stale-while-revalidate from the origin Cache-Control; also offers “Always Online” and configurable stale-serving in cache rules. cf-cache-status: STALE marks a stale-served hit. |
| Fastly | Yes | Full RFC 5861 support; stale-while-revalidate and stale-if-error work directly, and VCL can override via beresp.stale_while_revalidate in vcl_fetch. |
| CloudFront | Yes | Honours the origin’s stale-while-revalidate directive; behaviour interacts with the cache policy TTLs, so set Minimum/Default TTL sensibly and let the origin header drive freshness. |
| Nginx (self-hosted) | Passes through | Nginx emits the header for browsers/downstream caches; its own proxy_cache uses the separate proxy_cache_use_stale updating mechanism for equivalent behaviour. |
Tradeoff: SWR is a cache-serving policy, not a cache-key policy. It changes when a cached object is served stale — it does not change how variants are separated. If you serve format-negotiated media from one URL, you still need the correct Vary: Accept cache key alongside SWR, or you will happily serve a stale, wrong-format variant.
Choosing SWR windows by asset type
The right window balances two costs: too short and you lose the benefit (constant background revalidations); too long and you serve visibly outdated content. Match the window to how often the asset really changes and how bad staleness is.
| Asset type | Suggested directive | Rationale |
|---|---|---|
| Editorial / article images (versioned by path, not hash) | public, max-age=604800, stale-while-revalidate=86400 |
Rarely re-edited; a day of staleness is invisible, a week of freshness is plenty |
| CMS-generated thumbnails (non-fingerprinted) | public, max-age=86400, stale-while-revalidate=3600 |
May regenerate on re-crop; one-hour SWR smooths the expiry cliff |
| Video poster frames (mutable URL) | public, max-age=3600, stale-while-revalidate=600 |
Sometimes swapped when the video is re-cut; short window keeps the poster near-current |
HLS/DASH manifests (.m3u8, .mpd) |
public, max-age=6, stale-while-revalidate=30 |
Live playlists update constantly; tiny max-age with SWR avoids thundering-herd revalidation |
| Fingerprinted hero AVIF/WebP | public, max-age=31536000, immutable — no SWR |
Content-hashed URL never changes; use immutable, not SWR |
Tradeoff: A poster frame is the classic SWR candidate that people get wrong. It lives at a stable URL (so it is not immutable), but it also changes only occasionally (when the underlying clip is re-cut). A long max-age with a modest stale-while-revalidate gives you edge-speed delivery almost always, and a bounded window of staleness on the rare re-cut. Pair it with stale-if-error so a momentary origin blip never breaks the video element’s first paint.
Step-by-step: applying SWR
Step 1 — nginx origin headers
# Editorial images: mutable at a stable path (no content hash in the URL).
# max-age keeps them fresh for a week; SWR serves stale instantly for a day past expiry
# while a background request revalidates against the origin.
location ~* ^/editorial/.*\.(avif|webp|jpg|jpeg|png)$ {
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400" always;
# ETag lets the background revalidation return a cheap 304 instead of re-sending the body.
# Nginx sets ETag automatically for static files; keep it enabled.
etag on;
}
# Poster frames: stable URL, occasional swaps. Short SWR keeps the poster near-current.
location ~* ^/posters/.*\.(webp|jpg)$ {
# stale-if-error=3600 keeps the last good poster on screen if the origin 5xxes briefly
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=600, stale-if-error=3600" always;
etag on;
}
# HLS manifests: live playlists mutate every few seconds.
location ~* \.(m3u8|mpd)$ {
# Tiny max-age with a generous SWR window prevents a revalidation storm from every
# active viewer hitting the origin at the same expiry instant.
add_header Cache-Control "public, max-age=6, stale-while-revalidate=30" always;
}
Warning: Do not put this on your fingerprinted assets. Those keep Cache-Control: public, max-age=31536000, immutable from the parent Cache-Control guide — SWR on an immutable URL is dead weight.
Step 2 — Cloudflare (via a Cache Rule or _headers)
# Cloudflare Pages / Workers Sites _headers file
# Cloudflare honours stale-while-revalidate from the origin Cache-Control directly.
/editorial/*
Cache-Control: public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400
/posters/*
Cache-Control: public, max-age=3600, stale-while-revalidate=600, stale-if-error=3600
On a full Cloudflare zone, a Cache Rule can additionally set “Serve stale content while revalidating” and an Edge TTL, which layers on top of the origin directive. Watch cf-cache-status: a value of STALE confirms an SWR hit was served from the edge while a refresh ran.
Step 3 — Fastly VCL (optional override)
sub vcl_fetch {
# If the origin under-specifies, force sane stale windows for editorial media at the edge.
# These map directly to RFC 5861 semantics inside Fastly's cache.
if (req.url ~ "^/editorial/") {
set beresp.stale_while_revalidate = 86400s; # serve stale up to 1 day while revalidating
set beresp.stale_if_error = 86400s; # serve stale up to 1 day if origin errors
}
return(deliver);
}
Step 4 — CloudFront
CloudFront honours the origin’s stale-while-revalidate directive, but the cache policy TTLs gate it: if the policy’s Maximum TTL is shorter than your max-age, the object expires early and SWR never engages. Set the cache policy Minimum/Default/Maximum TTLs to bracket your intended max-age, and let the origin Cache-Control header carry the SWR window. Confirm forwarding of Accept in the cache key if the same URL negotiates formats.
Verification
Confirm the header and watch the Age climb
# 1. Read the directive and current cache age from a CDN edge.
curl -sI https://cdn.example.com/editorial/2026/summer-hero.avif \
| grep -iE 'cache-control|age|cf-cache-status|x-cache'
# cache-control: public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400
# age: 605012 ← if Age exceeds max-age but you still get an instant 200, SWR is working
# cf-cache-status: STALE ← Cloudflare: served stale while revalidating in background
# (CloudFront reports x-cache: RefreshHit from cloudfront on a comparable path)
Prove the stale-then-fresh sequence
# 2. Hit the asset repeatedly straddling the max-age boundary.
# The request right after expiry should return FAST (stale served) with a cache-status
# of STALE; the NEXT request should show a reset Age (background refresh completed).
for i in 1 2 3; do
curl -sI https://cdn.example.com/posters/launch.webp \
| grep -iE 'age|cf-cache-status'
sleep 2
done
# age: 3601 / cf-cache-status: STALE ← served stale instantly, refresh triggered
# age: 3603 / cf-cache-status: STALE
# age: 1 / cf-cache-status: HIT ← fresh copy now cached; boundary latency avoided
Confirm the background revalidation is cheap
# 3. A well-behaved revalidation should be a 304, not a full re-transfer.
# Send the ETag you received back as If-None-Match:
curl -sI https://origin.example.com/editorial/2026/summer-hero.avif \
-H 'If-None-Match: "6a1f-62b0c8f4"' \
| head -n1
# HTTP/2 304 ← origin confirms unchanged; only headers cross the wire, body is reused
Common mistakes and fixes
1. SWR on immutable, fingerprinted assets
Anti-pattern: Cache-Control: max-age=31536000, immutable, stale-while-revalidate=86400 on hero.8f4d2a.avif.
Effect: The URL is content-addressed, so the bytes never change — there is nothing to revalidate. The SWR directive is inert clutter and can confuse cache-policy audits.
Fix: Use immutable alone on fingerprinted URLs. Reserve SWR for mutable-at-a-stable-URL assets, as the parent guide’s asset table lays out.
2. An SWR window so long it serves outdated media for days
Anti-pattern: max-age=60, stale-while-revalidate=604800 on a thumbnail.
Effect: After the first minute, the edge can keep serving the stale thumbnail for a week while revalidations trickle through — so a re-cropped or corrected image lingers far longer than intended.
Fix: Size the SWR window to your tolerance for staleness, not the maximum you can get away with. Hours for thumbnails, a day at most for editorial, seconds for manifests.
3. Assuming the browser will honour SWR
Anti-pattern: Relying on SWR to smooth expiry for Safari users.
Effect: Safari 14 and 16 ignore stale-while-revalidate and block on synchronous revalidation, so those users still hit the latency cliff.
Fix: Treat SWR as primarily a CDN-edge win. For Safari-heavy audiences, lean on ETag/Last-Modified revalidation and consider warming the cache with preload hints before expiry.
4. SWR without stale-if-error
Anti-pattern: Adding stale-while-revalidate but omitting stale-if-error.
Effect: When the background revalidation hits a 5xx origin, some caches surface the error instead of continuing to serve the last good copy, breaking the image on an origin blip.
Fix: Pair the two: stale-while-revalidate=N, stale-if-error=M. SWR covers the happy path; stale-if-error covers the origin outage.
5. Forgetting the cache key on format-negotiated URLs
Anti-pattern: SWR on a single URL that returns AVIF or WebP by Accept, with no Vary.
Effect: The edge can serve a stale AND wrong-format variant — e.g. a stale AVIF to a WebP-only browser.
Fix: Keep Vary: Accept (or a normalised cache key) on server-negotiated endpoints, exactly as the Cache-Control cluster describes, then layer SWR on top.
Related
- Cache-Control headers for image and video assets — the full directive grammar,
immutablevs SWR, and theVary: Acceptcache-key rules - Best practices for setting max-age on CDN media assets — choosing the
max-agethat SWR extends, plus CDN purge workflows - MIME type configuration for modern media servers — ensure
Content-Typeis correct before tuning cache behaviour - Understanding video codecs: VP9 vs H.265 vs AV1 — poster-frame and manifest caching in context of multi-codec video delivery
- Core Media Fundamentals & Next-Gen Formats — parent section covering the encode-to-delivery pipeline