Cache-Control and stale-while-revalidate for CDN Caching

Six directives decide whether your CDN protects your origin or just proxies to it: max-age, s-maxage, stale-while-revalidate, stale-if-error, must-revalidate, and immutable. The one most teams ship without is stale-while-revalidate, and it is the one that removes the blocking origin fetch from the user path. On a hot object with a 60-second freshness lifetime spread across 40 edge caches, adding s-maxage=600, stale-while-revalidate=86400 cuts origin revalidations from roughly 2,400 to 240 per hour while every user request is answered from cache in single-digit milliseconds. This is a Cache-Control problem, not a vendor problem.

Cache-Control and stale-while-revalidate directives controlling CDN edge caching and origin revalidation flow

Which Cache-Control directives actually change CDN behaviour

Shared caches and private caches read the same header and reach different conclusions. RFC 9111 defines the freshness model, RFC 5861 adds the two stale extensions, and RFC 8246 adds immutable. Everything else in the header is either a permission or a prohibition. The table below covers only directives that measurably change what an edge node does.

DirectiveEffect at the CDN edgeMost common mistake
max-ageFreshness lifetime for every cache, browser includedUsing one value for both browser and edge, so a purge cannot reach the browser copy
s-maxageOverrides max-age for shared caches only; also overrides ExpiresAssuming it is additive to max-age instead of a replacement
stale-while-revalidateGrace window in which a stale response is served immediately while the edge revalidates asynchronouslyExpecting background refresh without traffic; the window only advances when a request arrives
stale-if-errorPermits serving stale on origin 5xx, connect failure or timeout, for the stated seconds past expirySetting it long enough to hide a multi-hour origin outage from dashboards
must-revalidateForbids any stale delivery; silently disables both stale extensionsShipping it alongside stale-while-revalidate and wondering why nothing changed
proxy-revalidateSame prohibition, shared caches only, browser keeps its graceConfusing it with private
no-cacheStore allowed, reuse only after successful validationUsed where no-store was meant, leaving copies at rest in shared caches
privateRemoves the object from the shared cache entirely; hit ratio goes to zeroEmitted by a framework default on pages that are fully public
immutableSuppresses reload-triggered revalidation for fingerprinted assetsApplied to URLs whose bytes can change under the same path

If you only change one thing after reading this table, separate browser TTL from edge TTL with max-age plus s-maxage, then add a stale window on top.

The revalidation cliff: what stale-while-revalidate saves at scale

Without a stale window, expiry is a cliff. The first request after the freshness lifetime ends pays the full origin round trip, and it is a real user. In 2026 measurements, a cross-region origin fetch for dynamic HTML lands in the 150–400 ms range once connect, TLS reuse, origin compute and transfer are counted, against 3–10 ms for an edge hit. That single request is a rounding error in p50 and it owns your p99.9.

The multiplier is cache fan-out. Each independent cache node that holds the object revalidates on its own schedule unless a shield tier collapses the requests.

stale-while-revalidate, defined in RFC 5861, lets a shared cache return an expired response instantly and refresh it in the background. On an object held by 40 independent edge caches, moving from s-maxage=60 to s-maxage=600, stale-while-revalidate=86400 reduces origin revalidations from about 2,400 to 240 per hour, a 10x drop, and none of the remaining 240 are on a user's critical path. As of 2026 most CDNs honor the directive on shared caches, while browser support stays uneven: Chromium-based browsers apply it in the private cache, others largely ignore it.

Cache-Control header CDN examples by content type

Personalised-free HTML, short edge TTL, long grace:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=86400, stale-if-error=259200

Fingerprinted JS or CSS bundle:
Cache-Control: public, max-age=31536000, immutable

API read model, JSON, tolerant of 5 seconds of drift:
Cache-Control: public, max-age=0, s-maxage=5, stale-while-revalidate=30, stale-if-error=600

HLS or DASH media playlist, 6-second segments:
Cache-Control: public, max-age=2, s-maxage=2, stale-while-revalidate=6

Media segment, immutable by URL:
Cache-Control: public, max-age=604800, s-maxage=2592000, immutable

Authenticated dashboard payload:
Cache-Control: private, no-store

Set max-age=0 with a non-zero s-maxage when you want purge authority: the browser always asks the edge, and the edge is the only place you have to invalidate.

If your origin sits behind nginx and you also cache there, the equivalent knobs are directive-level rather than header-level: enable background updates with proxy_cache_background_update on, allow stale delivery with proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504, and collapse concurrent misses with proxy_cache_lock on. Varnish expresses the same idea as grace plus a background fetch.

Trade-offs, failure modes and the observability gap

Stale delivery is a correctness concession you are choosing on purpose. Four failure modes worth naming.

  • Cold objects never refresh. The revalidation is request-triggered. An object with one hit per day and a 24-hour stale window is served stale on almost every request, forever.
  • stale-if-error masks outages. A 3-day window plus a healthy edge hit ratio can keep user-visible error rates near zero while the origin is down. Alert on origin 5xx and revalidation failure counts, not on edge status codes.
  • Purge semantics differ. On some CDNs a purge deletes the object outright, which destroys the stale copy that stale-if-error would have used. Verify whether your purge marks stale or evicts.
  • Vary and cookies quietly fragment everything. A response with Vary: Cookie or an Accept-Encoding variant explosion gets a per-variant stale window, multiplying revalidations by the number of variants.

The observability gap is that most teams cannot tell a fresh hit from a stale hit. RFC 9211 defines the Cache-Status response header, whose fresh and ttl parameters make that distinction explicit; where a CDN emits a proprietary status token instead, map STALE and UPDATING states into the same metric. Configurable response headers and log fields per cache tier are the practical requirement here, which is why edge-level control belongs on the checklist when you review CDN edge caching and header control features rather than being treated as a detail.

Verify this on your own stack

  • Take a HEAD response for your top 20 URLs by volume and read the Age header. If Age never exceeds your s-maxage, no stale serving is happening.
  • Grep origin access logs for conditional requests carrying If-None-Match or If-Modified-Since and count 304s per hour per URL. That count is your fan-out multiplier.
  • Check for must-revalidate, private, or no-cache injected by a framework or middleware. Any one of them nullifies the stale extensions.
  • Compare origin requests per second before and after a 10x s-maxage increase with a stale window. A drop smaller than 3x means variants, not TTL, are your bottleneck.

Who this fits and who it doesn't

It fits catalogue pages, marketing pages, search result pages, API read models, playlists, config blobs, and anything where a few seconds of drift is cheaper than a slow response. It does not fit inventory counts at checkout, payment state, authorization decisions, or one-time tokens. For those, private, no-store is the answer and the caching win has to come from moving the read model, not the response.

FAQ: Cache-Control, stale-while-revalidate and stale-if-error

What does stale-while-revalidate do in a CDN?

stale-while-revalidate defines a grace period after a response's freshness lifetime during which a CDN may serve the cached copy immediately and revalidate it against the origin in the background. The user gets an edge hit in single-digit milliseconds instead of a 150–400 ms origin fetch. The refresh only fires when a request arrives.

What is the difference between max-age and s-maxage?

max-age sets the freshness lifetime for all caches; s-maxage overrides it for shared caches such as a CDN and also overrides the Expires header. Pairing max-age=0 with a non-zero s-maxage keeps browsers asking the edge on every navigation while the edge still absorbs the traffic, which makes purge the single source of truth.

Does stale-if-error work if the origin returns a 404?

No. stale-if-error applies to 500, 502, 503, 504, connection failures and timeouts. A 404 or 410 is a valid answer from a healthy origin, so it replaces the cached entry rather than triggering stale delivery. Deleting a record at origin will therefore propagate to users even with a long stale-if-error window.

Why is stale-while-revalidate being ignored by my CDN?

Check for must-revalidate or proxy-revalidate in the same Cache-Control header, since both forbid stale reuse outright. Other causes: private or no-store on the response, an origin-side cache configuration overriding response headers, a Surrogate-Control header taking precedence, or a purge that evicted the stale copy instead of marking it stale.

Run the two-header experiment this week

Pick the ten highest-volume cacheable URLs on your service. Log Age, Cache-Status and origin requests per second for 24 hours as a baseline. Then change one thing: raise s-maxage by 10x and add stale-while-revalidate equal to ten times that value, leaving max-age untouched. Re-measure for another 24 hours and compare origin RPS, p99 TTFB, and the share of responses served stale. If origin load does not fall at least 3x, your problem is cache-key fragmentation, and that is the next thing to fix.