Serving Stale Content with stale-while-revalidate
TL;DR: stale-while-revalidate lets a cache hand a client the last stored response the instant the freshness lifetime ends, while it fetches a fresh copy from origin in the background — no client ever waits on that origin round-trip. stale-if-error is the resilience counterpart: it authorizes serving that same stale copy when a revalidation attempt fails outright, instead of surfacing a 5xx to the user.
Quick-reference header block for an endpoint that should stay fast during normal expiry and resilient during an origin blip:
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
Mechanism and RFC Alignment
stale-while-revalidate and stale-if-error are defined in RFC 5861, an extension to the core HTTP caching model. RFC 9111 does not define either directive itself, but it explicitly accommodates them: Section 5.2.3 permits new cache-control directives as extensions, and caches that do not recognize stale-while-revalidate or stale-if-error simply ignore them and fall back to standard revalidate-when-stale behavior. Nothing about the extension breaks compliant caches that predate it — this is why the directives could ship broadly across CDNs without a protocol version bump.
The mechanism runs in three stages once a stored response passes its freshness lifetime:
- Stale check — on each incoming request, the cache computes current age against
freshness_lifetime + stale-while-revalidate. If age falls inside that combined window, the entry is stale but still eligible for immediate delivery. - Immediate stale delivery — the cache returns the stored response to the client without waiting on origin. Latency for that request is identical to a normal hit.
- Asynchronous revalidation — the cache dispatches a conditional (or unconditional) request to origin in the background, independent of the client response already sent. If a revalidation for that same key is already in flight, a well-behaved cache does not start a second one. When the background fetch completes, the store is updated and subsequent requests see the refreshed entry with
Agereset near zero.
stale-if-error follows the same stale-serving mechanic but gates on failure rather than elapsed time alone: it only authorizes stale delivery when a fetch or revalidation attempt to origin actually errors — a 5xx response, a timeout, or a connection failure. Its grace window is computed the same way, as freshness_lifetime + stale-if-error, but measured independently from the stale-while-revalidate window; a response can carry both directives with different values, and each governs a different trigger condition.
Both directives operate only where a definite freshness_lifetime already exists. Heuristic freshness (RFC 9111 §4.2.2) — the fallback caches apply when no max-age, s-maxage, or Expires is present — has no defined interaction with either extension, since there is no explicit expiry point for the grace window to extend from.
It helps to think of the two directives as solving different problems that happen to share a mechanism. stale-while-revalidate is a latency optimization: it removes the synchronous origin round-trip that would otherwise land on whichever unlucky client’s request happens to arrive right after expiry. stale-if-error is a resilience mechanism: it converts an origin failure — a database timeout, a deploy that briefly returns 500s, a network partition to one origin region — into a slightly-stale-but-successful response instead of a visible outage. Because they gate on different conditions (elapsed time versus fetch failure), a response can reasonably carry a short stale-while-revalidate window for everyday traffic smoothing and a much longer stale-if-error window as a safety net, since the latter should almost never actually trigger in a healthy system.
Scope and Precedence
Not every directive combination permits stale serving. RFC 5861 is explicit that must-revalidate and proxy-revalidate disable both extensions outright:
| Directive present | Effect on stale-while-revalidate / stale-if-error |
|---|---|
no-store |
No storage occurs at all; the extensions have nothing to act on. |
no-cache |
Storage is permitted but every use requires revalidation first; there is no window in which a stale copy can be served without checking origin. |
must-revalidate |
Forbidden. Per RFC 9111 §4.2.4, a cache must not serve a stale response once the freshness lifetime ends — this overrides any stale-while-revalidate or stale-if-error value in the same response, including during an origin outage. |
proxy-revalidate |
Forbidden for shared caches specifically. A private browser cache holding the same response is not bound by proxy-revalidate and may still apply the extensions if it implements them. |
| None of the above | Both extensions apply as configured. |
This has a practical consequence: a team that adds stale-while-revalidate=600 to an endpoint that already carries must-revalidate for correctness reasons (say, a pricing API) gets no grace window at all — the must-revalidate directive wins, and every request past expiry blocks on a synchronous origin fetch exactly as if the extension were never added. Auditing for this combination is one of the most common fixes needed when a “why isn’t stale-while-revalidate working” report comes in.
Cache tier support is uneven. CDNs and reverse proxies — Fastly, Varnish, and increasingly Cloudflare depending on plan and configuration — implement the grace window reliably at the shared-cache tier, which is where most of the latency win matters anyway. Browser HTTP cache implementations have historically given the header directive partial or no effect; do not assume a browser tab left open past expiry will silently serve stale-then-refresh the way a CDN edge does. If you need that exact pattern client-side, it is implemented separately via a Service Worker and the Cache API — a JavaScript-level mechanism, not the HTTP Cache-Control extension described here.
Vary interacts with the grace window the same way it interacts with ordinary freshness: each Vary-keyed variant is tracked and revalidated independently. A background revalidation for one Accept-Encoding variant does not refresh sibling variants; each must age past its own freshness lifetime before its own grace window opens.
Diagram: The Freshness, Grace, and Hard-Stale Windows
Implementation Patterns
Pattern 1: Content page with moderate churn
Cache-Control: public, s-maxage=300, stale-while-revalidate=3600
A blog index or category listing that changes every few minutes but where a 10–20 minute-old view is harmless. The five-minute s-maxage keeps the shared cache reasonably fresh, and the hour-long grace window absorbs traffic spikes at expiry without ever forcing a synchronous origin fetch.
Pattern 2: Latency-sensitive API with error resilience
Cache-Control: public, max-age=10, stale-while-revalidate=30, stale-if-error=300
Content-Type: application/json
A short ten-second freshness lifetime keeps data close to real time, the thirty-second grace window smooths over normal revalidation latency, and the five-minute stale-if-error window means a transient origin 500 or database timeout degrades to “slightly old data” instead of a visible outage for API consumers.
Pattern 3: Authenticated, user-scoped response
Cache-Control: private, no-store
Responses scoped to one authenticated user should generally not be stored by shared caches at all. stale-while-revalidate has no meaningful role here: with no-store, nothing is retained to serve stale in the first place, and even private alone limits any stale-serving benefit to a single user’s own browser cache, where the extension’s support is least reliable. See no-cache vs no-store for the full decision tree on when storage is appropriate at all.
Pattern 4: CDN and browser split
Cache-Control: public, s-maxage=60, stale-while-revalidate=600, max-age=0
The CDN tier gets a real freshness lifetime plus a generous grace window and will almost always serve instantly. The browser tier, with max-age=0, treats the response as immediately stale on arrival and revalidates on next use — appropriate when you want every browser tab to double-check against the (fast, CDN-fronted) origin, while the CDN itself absorbs the actual latency cost of that check.
Pattern 5: Explicitly forbidding stale delivery
Cache-Control: public, max-age=30, must-revalidate
For content where correctness matters more than availability — live pricing, inventory counts, one-time tokens — must-revalidate guarantees no cache, however configured elsewhere in the stack, will serve a stale copy past expiry. Adding stale-while-revalidate to a response like this is a no-op per RFC 5861 and should be treated as a configuration smell during review.
Server and CDN Configuration
Nginx
NGINX’s open-source build does not automatically parse the numeric stale-while-revalidate or stale-if-error values out of an upstream Cache-Control header — you reproduce the behavior explicitly with proxy_cache_use_stale and proxy_cache_background_update:
proxy_cache_path /var/cache/nginx keys_zone=api_cache:10m max_size=1g inactive=60m use_temp_path=off;
server {
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 10s;
# Equivalent of stale-while-revalidate + stale-if-error
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}
updating lets NGINX serve the previous cached response while a refresh for that same key is already underway — the stale-while-revalidate behavior. error, timeout, and the http_5xx codes are the stale-if-error equivalent, serving the last good copy when the upstream fails outright. proxy_cache_lock prevents concurrent requests from triggering redundant background fetches for the same key.
Apache
mod_cache_disk has no first-class numeric grace-window directive equivalent to stale-while-revalidate. The closest native behavior comes from retaining expired entries and serving them opportunistically when the origin is unreachable:
CacheQuickHandler off
CacheLock on
CacheLockPath /tmp/mod_cache-lock
CacheLockMaxAge 5
CacheStoreExpired On
CacheEnable disk /api/
CacheDefaultExpire 10
CacheStoreExpired On keeps expired entries available rather than discarding them immediately, which approximates stale-if-error when the origin is down. Apache does not run an equivalent background-refresh timer on its own; if you need a true time-boxed stale-while-revalidate window on Apache, pair it with an external warmer hitting hot URLs just past expiry, or place Varnish or NGINX in front of it to own the stale-serving logic.
Fastly (VCL)
Fastly’s default fetch handling honors stale-while-revalidate and stale-if-error from the origin’s Cache-Control header without any VCL changes. To set or override the values explicitly at the edge:
sub vcl_fetch {
if (beresp.http.Cache-Control ~ "stale-while-revalidate=([0-9]+)") {
set beresp.stale_while_revalidate = std.atoi(re.group.1);
}
if (beresp.http.Cache-Control ~ "stale-if-error=([0-9]+)") {
set beresp.stale_if_error = std.atoi(re.group.1);
}
}
Fastly enforces its own ceiling on both windows regardless of the value sent — check current account documentation before relying on very large windows (multi-day) for either directive.
Cloudflare’s behavior here depends heavily on plan and Cache Rules configuration, and has changed over product versions — see Implementing stale-while-revalidate on Cloudflare for the current verification steps and a Workers-based fallback.
Interaction with Related Directives
max-age / s-maxage and the grace window — the extensions are additive on top of whichever freshness lifetime precedence resolves to. There is no independent “stale clock” — age is still tracked from the same Date and Age bookkeeping described in the freshness calculation, just compared against a longer allowed window.
no-cache and stale serving — no-cache requires revalidation before every use, which leaves no interval in which a stale copy can legitimately be handed to a client. Combining no-cache with stale-while-revalidate is contradictory; the revalidation requirement wins.
immutable and stale serving — content marked immutable is defined to never change for the lifetime of its URL, so there is conceptually nothing to go stale until a new URL replaces it. stale-while-revalidate adds no value on immutable, hash-named assets; use it on mutable resources instead.
Surrogate keys and tag purges — a tag-based purge evicts the cache entry outright rather than marking it stale, so a purged entry has nothing left for stale-while-revalidate to serve. The next request after a purge is a normal miss, not a stale-then-refresh delivery — plan invalidation-sensitive content accordingly if you’re relying on the grace window to smooth traffic.
ETag / Last-Modified and the background fetch — the asynchronous revalidation triggered inside the grace window is a normal conditional request: it sends If-None-Match or If-Modified-Since and expects a 304 if nothing changed. stale-while-revalidate governs when that background request fires; it does not change how the validators themselves work.
Verification Workflow
Step 1 — Confirm the directives are present on the live response
curl -sI https://example.com/api/products/9472 \
| grep -iE 'cache-control|age'
Confirm stale-while-revalidate (and stale-if-error, if configured) appear in Cache-Control alongside an explicit max-age or s-maxage. Without one of those, the grace window has no anchor point.
Step 2 — Warm the cache and let it pass its freshness lifetime
# Warm the entry
curl -sI https://example.com/api/products/9472 | grep -i age
# Wait past the configured max-age / s-maxage
sleep 15
# Request again inside the grace window
curl -s -o /dev/null -w 'time_total: %{time_total}s\n' https://example.com/api/products/9472
time_total for the request made just after expiry should look identical to a normal cache hit, not to a full origin round-trip. A visible latency spike here means the cache is not honoring the grace window — check for a stray must-revalidate or a proxy that doesn’t implement the extension.
Step 3 — Confirm the background refresh actually completes
sleep 5
curl -sI https://example.com/api/products/9472 | grep -i age
Age should now be small again, confirming the background revalidation replaced the entry. If Age keeps climbing past the configured grace window instead of resetting, the background fetch is failing silently — check origin logs and reverse-proxy error logs for the same time window.
Step 4 — Simulate an origin failure to test stale-if-error
Point the cache at a deliberately broken upstream (or block it at the network layer briefly) and repeat the request from Step 2. A correctly configured stale-if-error window returns the last good response with a 200 instead of propagating a 502/504 to the client. Confirm this in application or CDN logs rather than relying on the client-visible status code alone, since a silent stale delivery can otherwise mask a real outage from monitoring.
Step 5 — Cross-check in DevTools
- Open Chrome DevTools, select the Network tab, and reload with “Disable cache” unchecked.
- Click the request and inspect the Headers panel for
Cache-Control,Age, and any CDN-specific status header your provider exposes. - A request served during the grace window should still show a fast
Timevalue comparable to a cache hit, withAgegreater than the configuredmax-age/s-maxage— that combination is the visual signature of a stale-while-revalidate delivery rather than a genuine fresh hit. - Reload again a few seconds later and confirm
Agehas reset to a small value, showing the background refresh landed.
Failure Modes and Gotchas
- Origin never sends the directive — a reverse proxy cannot invent a numeric grace window from nothing. If the origin’s
Cache-Controlomitsstale-while-revalidate, NGINX and Apache need explicit local configuration (proxy_cache_use_stale,CacheStoreExpired) to approximate the behavior at all. must-revalidatesilently cancels the grace window — the single most common misconfiguration. Both directives can appear in the same response with no error;must-revalidatesimply wins, and the grace window never activates.- CDN-enforced ceilings — most CDNs cap the effective
stale-while-revalidateorstale-if-errorwindow regardless of the configured value. Sendingstale-while-revalidate=2592000(30 days) does not guarantee 30 days of grace on every platform. - Revalidation storms without single-flight collapsing — if many concurrent requests land during the grace window and the cache doesn’t collapse them into one background fetch, each can trigger its own redundant origin request. See origin shielding and request collapsing for the mechanism that prevents this.
- Assuming browser support — browsers largely do not apply this directive at the HTTP cache layer the way CDNs do. Verify per browser rather than assuming RFC 5861 semantics carry through to the client.
- Clock skew corrupting the window math — since the grace window is computed from the same age accounting as ordinary freshness, unsynchronized clocks between cache and origin can push a response into or out of its grace window unexpectedly. Keep all nodes on NTP.
stale-if-errormasking real outages — because it degrades gracefully by design, an origin that has been down for hours can go unnoticed if nobody is watchingAge, error-rate dashboards, or CDN-specific stale-state indicators. Instrument for this explicitly; don’t rely on the absence of client-facing errors as a health signal.
Frequently Asked Questions
Does stale-while-revalidate work in the browser’s HTTP cache?
Inconsistently. Most of the real-world benefit comes from CDNs and reverse proxies, which implement the grace window reliably. Browser support at the HTTP cache layer has historically been partial or absent, so verify empirically rather than assuming client-side compliance.
What is the difference between stale-while-revalidate and stale-if-error?
stale-while-revalidate opens a grace window that applies on every request past expiry, refreshing asynchronously in the background. stale-if-error opens a separate window that only activates when a revalidation or fetch attempt fails outright, letting the cache fall back to the last-known-good response instead of surfacing the error.
Can I set stale-while-revalidate without max-age or s-maxage?
It has no defined anchor point without one. The grace window extends a concrete freshness lifetime; without max-age or s-maxage, caches fall back to heuristic freshness or treat the response as immediately stale, and there’s no fixed expiry moment for the window to start from.
Does must-revalidate always override stale-while-revalidate?
Yes. RFC 5861 states plainly that must-revalidate and proxy-revalidate disable both extensions. A cache must revalidate synchronously once freshness ends, with no exception for origin errors.
Is there a maximum value for stale-while-revalidate?
RFC 5861 sets no upper bound, but individual CDNs commonly enforce their own ceiling regardless of the value sent. Check your platform’s documented cap before relying on a very large window.
Related
- Conditional Requests and 304 Responses — the background revalidation triggered inside the grace window is an ordinary conditional request under the hood.
- ETag Generation and Validation Strategies — the validator the background fetch relies on to determine whether the stale copy can simply be marked fresh again.
- How must-revalidate Interacts with max-age — a closer look at the directive that fully disables stale serving.
- Interpreting X-Cache and CF-Cache-Status Headers — how to read the response headers that reveal whether a request was served fresh, stale, or revalidated.
- How to Read and Interpret the Age Header — the same
Ageaccounting used to compute both the freshness lifetime and the grace windows described here.