Serving Stale Content with stale-while-revalidate
This guide shows you how to configure stale-while-revalidate and stale-if-error end to end so your CDN serves a cached response instantly while it refreshes the origin in the background, and keeps serving content even when the origin returns 5xx or times out. After reading you will be able to emit the correct Cache-Control directives from your origin, replicate or override them at the edge with a Cloudflare Worker or Fastly VCL, and prove the behaviour with curl -I and a deliberately faulted origin.
Key objectives:
- Emit
max-age,stale-while-revalidate, andstale-if-errorfrom the origin and confirm the CDN honours them. - Replicate the directives at the edge when a CDN tier strips or ignores them.
- Test asynchronous revalidation and the error-fallback path by faulting the origin on purpose.
- Verify with
curl -IthatAgeexceedsmax-agewhile the response is still a cacheHIT.
The pattern matters because a plain max-age forces a blocking revalidation the moment content expires: the first request after expiry waits on a full origin round trip, and if the origin is slow or down that request fails. stale-while-revalidate decouples freshness from latency by letting the edge answer from cache immediately and refresh out of band, while stale-if-error turns the cache into a resilience layer that masks origin outages.
Prerequisites and environment setup
You need one of two things: control over the Cache-Control header your origin emits, or the ability to override caching at the edge. Confirm your tooling before starting:
curl --version # any recent build
dig -v # bind-tools, for sanity checks
wrangler --version # 3.x or later, if using Cloudflare Workers
fastly version # if using Fastly VCL
For Cloudflare, the hostname must be proxied (orange-cloud) so requests pass through the edge cache. Cloudflare honours stale-while-revalidate and stale-if-error on the standard cache for proxied traffic; on free and lower tiers the directives can be ignored, which is exactly the failure mode the parent topic on resilient caching addresses with edge overrides. Make sure you have already settled your base Cache-Control headers for static and dynamic content before layering staleness on top, because the two directive sets share the same header.
Step 1: Emit the directives from the origin
The cleanest implementation lives at the origin. Set a short fresh window with a long stale window so the edge can absorb a full day of origin trouble. The values below give a ten-minute freshness lifetime, a 24-hour background-refresh window, and a 24-hour error-fallback window.
For Nginx, add the header on the location serving cacheable content:
location /api/catalog {
proxy_pass http://app_upstream;
add_header Cache-Control "public, max-age=600, stale-while-revalidate=86400, stale-if-error=86400" always;
}
The always flag ensures the header is sent even on 5xx upstream responses, which matters for the stale-if-error path. For an Express origin:
app.get("/api/catalog", (req, res) => {
res.set(
"Cache-Control",
"public, max-age=600, stale-while-revalidate=86400, stale-if-error=86400"
);
res.json(buildCatalog());
});
Expected side effect: every response now carries the staleness directives. Confirm with a direct origin request that bypasses the CDN:
curl -sI https://origin.internal.example.com/api/catalog | grep -i cache-control
# cache-control: public, max-age=600, stale-while-revalidate=86400, stale-if-error=86400
If you cannot reach the origin directly, query the CDN with a cache-busting query string and a Pragma: no-cache to force a pass-through, then inspect the upstream header.
Step 2: Confirm the CDN honours the directives
Send two requests through the edge spaced more than max-age apart and watch the Age and cache-status headers. Cloudflare reports state in cf-cache-status; Fastly uses X-Cache and Age.
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status|x-cache'
# cf-cache-status: MISS
# age: 0
Wait past the fresh window and repeat:
sleep 605
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status'
# cf-cache-status: HIT
# age: 612
The decisive signal is an Age that exceeds max-age while the status is still HIT. That proves the edge served a stale object instead of blocking on the origin. A second request a beat later should show Age reset toward zero, indicating the background revalidation completed and refreshed the cached object.
Step 3: Replicate the directives at the edge with a Worker
When a tier strips the directives, or when you do not control the origin header, set the policy at the edge. This Cloudflare Worker overrides Cache-Control on the response before it enters the cache and uses the Cache API so the edge applies the stale windows even if the origin sends only max-age.
export default {
async fetch(request, env, ctx) {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
let response = await cache.match(cacheKey);
if (response) return response;
response = await fetch(request);
response = new Response(response.body, response);
response.headers.set(
"Cache-Control",
"public, max-age=600, stale-while-revalidate=86400, stale-if-error=86400"
);
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
Use ctx.waitUntil so the cache.put finishes after the response is returned to the client. Deploy and tail it:
wrangler deploy
wrangler tail --format pretty
When you control purges, pair this with your deploy pipeline that handles purging the Cloudflare cache via the API on deploy so a fresh release invalidates objects that would otherwise sit stale for the full window.
Step 4: Replicate the directives in Fastly VCL
Fastly maps these directives onto obj.ttl, obj.stale_while_revalidate, and obj.stale_if_error. Set them in vcl_fetch (or vcl_backend_response on the newer VCL):
sub vcl_fetch {
set beresp.ttl = 600s;
set beresp.stale_while_revalidate = 86400s;
set beresp.stale_if_error = 86400s;
return(deliver);
}
Add a deliver-time header so you can observe which path served the request:
sub vcl_deliver {
set resp.http.X-Cache-State = obj.hits > 0 ? "HIT" : "MISS";
set resp.http.Age = obj.age;
}
Fastly serves the stale object and triggers an asynchronous fetch automatically once obj.ttl is exhausted but the revalidate window is still open.
Step 5: Fault the origin to test error fallback
The stale-if-error path only proves itself when the origin actually fails. Let an object go stale (request it, wait past max-age), then break the origin and request again.
# Take the origin offline or return 503 from it
ssh origin "systemctl stop app"
# Request through the edge while origin is down
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status|http/'
# HTTP/2 200
# cf-cache-status: HIT
# age: 4203
You get a 200 with a high Age even though the origin returns 503, because the edge falls back to the stale object inside the stale-if-error window. Restart the origin and confirm the next revalidation refreshes the object and Age resets.
| Request phase | Origin state | Edge response | Origin contacted |
|---|---|---|---|
Inside max-age |
up | fresh HIT | no |
max-age to +swr |
up | stale HIT | yes, async background |
max-age to +swr |
down | stale HIT | attempt, ignored |
Inside stale-if-error |
down | stale HIT | attempt fails, stale served |
| Past all windows | down | error (504/origin 5xx) | yes, blocking |
Verification
Run the full sequence and read the headers, not the body. The three signals that confirm correct behaviour:
# 1. Fresh window: low Age, HIT after first MISS
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status'
# 2. Stale window: Age > max-age, still HIT
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status'
# 3. After background revalidation: Age drops back below max-age
curl -sI https://www.example.com/api/catalog | grep -iE 'age|cf-cache-status'
Age exceeding max-age on a HIT is the canonical proof that stale serving is active. If Age never climbs past max-age and every expired request shows a MISS, the directives are not being honoured and you are still in blocking-revalidation mode.
Troubleshooting
Directive ignored by tier
Symptom: Age never exceeds max-age; expired requests always show MISS and block on the origin. Diagnose by comparing the origin header to the edge header.
curl -sI https://origin.internal.example.com/api/catalog | grep -i cache-control
curl -sI https://www.example.com/api/catalog | grep -i cache-control
If the origin emits the directives but the edge response drops them, the tier is stripping or ignoring them. Fix by moving the policy into a Worker or VCL as in Steps 3 and 4, which forces the edge to apply the windows regardless of plan.
Stale never served
Symptom: a cf-cache-status: REVALIDATED or EXPIRED appears instead of a stale HIT. This usually means the object never entered the cache as cacheable, often because of a Set-Cookie header, a no-store directive sneaking in, or a Vary header that fragments the cache. Inspect the full response:
curl -sI https://www.example.com/api/catalog
Remove Set-Cookie on cacheable responses, strip stray private/no-store directives, and audit your Vary and cache-key configuration so identical requests share an object rather than each landing as a fresh MISS.
Origin still hammered during the stale window
Symptom: origin request rate matches client request rate even though stale-while-revalidate is set. The edge should collapse background refreshes into a single revalidation per object per window, so a flood of origin hits means revalidation is not being deduplicated. Check that you are not bypassing the cache with a unique query string or Cache-Control: no-cache request header on each client call, and confirm the Worker uses a stable cacheKey. In Cloudflare, enabling Tiered Cache concentrates revalidations through an upper tier so the origin sees one refresh rather than one per edge location.
Background refresh never completes
Symptom: Age keeps climbing and never resets. The asynchronous fetch is failing silently. In a Worker, confirm ctx.waitUntil(cache.put(...)) wraps the store and that the revalidation fetch returns a cacheable status; a 500 from the origin will not replace the cached object. Tail the Worker during a stale request:
wrangler tail --format pretty
Look for the background fetch and a successful cache.put. If the fetch errors, the stale object persists until the stale-if-error window expires, then requests start failing.
Frequently Asked Questions
Does stale-while-revalidate serve a stale response to the very first user after expiry?
Yes. That is the point. The first request after max-age expires receives the stale cached object immediately while the edge fetches a fresh copy in the background, so no client ever pays the revalidation latency as long as the request falls inside the stale-while-revalidate window.
What is the difference between stale-while-revalidate and stale-if-error?
stale-while-revalidate covers the normal case where the object expired but the origin is healthy, refreshing it asynchronously. stale-if-error covers the failure case where the origin returns a 5xx or times out, letting the edge keep serving the stale object instead of propagating the error. You typically set both.
Why does Age exceed max-age on a HIT?
Because the edge is intentionally serving a stale object. max-age defines freshness, not eviction; once it is exceeded the object is stale but still usable within the staleness windows, so a HIT with an Age larger than max-age is the expected signal that stale serving works.
Do browsers honour these directives too?
Browsers support stale-while-revalidate in the private cache, but coverage of stale-if-error is inconsistent. Treat the CDN as the authoritative layer for resilience and do not rely on the browser to mask origin outages.
How long should the stale windows be?
Size stale-while-revalidate to how stale you can tolerate content being under normal load, and stale-if-error to your worst acceptable origin outage. A 24-hour stale-if-error means a full day of origin downtime is invisible to users for any object that was cached before the outage began.