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:

Cache freshness timeline: max-age, stale-while-revalidate, stale-if-error A horizontal timeline. From 0 to 300 seconds the object is fresh and served from cache. From 300 to 900 seconds it is stale but served instantly while a background revalidation runs. Beyond 900 seconds requests block on revalidation, and a separate stale-if-error window covers origin 5xx responses. time → fresh (max-age) served from cache stale-while-revalidate stale served instantly + background refresh blocks on revalidation 0s 300s 900s stale-if-error: on origin 5xx, keep serving the last good copy across this whole span

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, immutableno 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.