stale-while-revalidate is a Cache-Control directive that lets a cache keep serving an expired response for a fixed number of seconds while it fetches a fresh copy in the background. Cache-Control: max-age=600, stale-while-revalidate=3600 means fresh for ten minutes, then servable stale for another hour, provided the cache kicks off a refresh at the same time.
That is the easy part. The part that costs a day is discovering that the same header behaves differently on Cloudflare and on Vercel, and that adding s-maxage to it can switch stale serving off entirely.
What stale-while-revalidate does in a Cache-Control header
RFC 5861 defines it in two sentences. A cache “MAY serve the response in which it appears after it becomes stale, up to the indicated number of seconds”, and if it does, it “SHOULD attempt to revalidate it while still serving stale responses (i.e., without blocking)”. Once the window elapses without a successful revalidation, the stale copy “SHOULD NOT continue to be served”.
So a cached response has three lives rather than two. Fresh, where it is served with no origin contact. Stale but inside the window, where it is served immediately and a background fetch goes out. Then expired, where the next request waits for the origin.
The directive lands in browsers too: MDN’s compatibility data puts it in Chrome 75, Firefox 68 and Safari 14. Browser caches are small and per-user though, so almost all of the value is in the shared cache sitting in front of your origin.
Why s-maxage can silently disable it
Here is the trap. Cloudflare’s Origin Cache Control docs state it plainly: “Per RFC 9111, s-maxage incorporates the semantics of proxy-revalidate, which means a shared cache must not serve stale content without first revalidating with the origin.”
Write Cache-Control: s-maxage=60, stale-while-revalidate=600 and, on Cloudflare with Origin Cache Control on (the default for Free, Pro and Business plans), you get no stale serving at all. Every request after 60 seconds blocks on the origin. The header looks correct. The behaviour is the opposite of what you wanted.
Now read Vercel’s cache-control reference, which gives Cache-Control: s-maxage=1, stale-while-revalidate=59 as the canonical recipe and describes exactly the asynchronous background refresh you expected. Same header, two vendors, opposite outcomes.
must-revalidate and no-cache are the other two that suppress stale serving on Cloudflare once Origin Cache Control is enabled. Neither of those surprises anyone. s-maxage does, because everywhere else it reads as “the CDN number”.
How to split browser and CDN policy with CDN-Cache-Control
Stop overloading one header. RFC 9213 registers CDN-Cache-Control as a targeted field: a cache that implements it “MUST select the first (in target-list order) field with a valid, non-empty value” and, having done so, must “ignore the Cache-Control and Expires header fields in that response”.
Each vendor extends that target list with its own name, most specific first:
- Vercel reads
Vercel-CDN-Cache-Control, thenCDN-Cache-Control, thenCache-Control. - Cloudflare reads
Cloudflare-CDN-Cache-Control, thenCDN-Cache-Control, thenCache-Control. Cache Response Rules that set directives override all three. - Netlify reads
Netlify-CDN-Cache-Control, thenCDN-Cache-Control, thenCache-Control.
The vendor-specific header is stripped before the response reaches the browser. CDN-Cache-Control is forwarded, so any other CDN in the chain still sees it. Vercel goes further and consumes s-maxage, stale-while-revalidate and stale-if-error out of plain Cache-Control for every request, so what your browser devtools show is not what your function returned.
A handler that says one thing to browsers and another to the edge:
export function GET(): Response {
return new Response(renderPricingPage(), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
// Browsers: short, so a returning visitor picks up changes quickly.
'Cache-Control': 'public, max-age=60',
// Any CDN in the path. Note the deliberate absence of s-maxage.
'CDN-Cache-Control':
'public, max-age=300, stale-while-revalidate=86400, stale-if-error=86400',
},
});
}
Five minutes of freshness at the edge, a day of stale cover behind it, and a browser cache short enough that nobody is stuck on yesterday’s price. If you also want a different number on Vercel’s own cache specifically, add Vercel-CDN-Cache-Control and it wins there without touching what other caches see.
Is stale-if-error worth setting too?
Yes, and it costs nothing. stale-if-error lets a cache serve a stale response “when an error is encountered … regardless of other freshness information”, per the same RFC. MDN counts 500, 502, 503 and 504 as errors; Vercel also counts network and DNS failures.
The limits matter. It only covers URLs already in the cache, so a cold path still returns your 500 to the visitor. And its window is an upper bound on staleness, not an indefinite lifeline: once the response is more stale than the value you set, the error goes through.
Set it generously. A day of stale-if-error on marketing pages turns an origin outage into something only your monitoring notices.
What to set for HTML, APIs and hashed assets
Content-hashed bundles are the one case with a settled answer: public, max-age=31536000, immutable. Worth knowing that immutable is not universal. MDN’s data has it in Firefox 49 and Safari 11, and not in Chrome, so treat it as a bonus rather than the mechanism.
Shared HTML that every visitor sees identically is where the split above earns its keep. Short browser TTL, long CDN TTL, generous stale window, and a purge on deploy so the long TTL never bites you. Netlify does this with a Netlify-Cache-Tag response header and a purge API (rate limited to two purges per tag every five seconds); Cloudflare does it through Cache Response Rules and tag purging on Enterprise.
Personalised HTML should not go near a shared cache. Use private, no-store and move the personal fragment out of the cached document instead. That is the whole argument for Astro’s server islands, and the same shape works in any framework that can stream a hole into a static shell.
JSON APIs follow the HTML rules with one extra worry: Vary. If the response differs by Accept-Language or an auth header and you have not declared it, a shared cache will hand one user another user’s body.
How to check it is actually serving stale
Headers, not hope. Age tells you how long the shared cache has held the response, and the vendor status header tells you what it did:
for i in 1 2 3; do
curl -sS -o /dev/null -D - https://example.com/pricing \
| grep -iE '^(age|cache-control|cdn-cache-control|cf-cache-status|x-vercel-cache):'
sleep 2
done
On Cloudflare you are looking for cf-cache-status: UPDATING, which is what a stale hit with a background refresh in flight reports. Cloudflare only made that path fully asynchronous in February 2026; before then the first request after expiry could block. On Vercel, x-vercel-cache: STALE is the equivalent, and REVALIDATED means the refresh ran synchronously.
Which brings up the last gotcha. Vercel revalidates synchronously when the request carries Pragma: no-cache, and browser devtools set that header by default with the network panel open. Your own testing is the slowest possible path through the system. Measure with curl.
If I were setting this up today on a CDN-fronted site: put the edge policy in CDN-Cache-Control, keep Cache-Control for the browser only, never pair s-maxage with stale-while-revalidate, and lean on deploy-time purging rather than a one-second TTL. The exception is a page that is already cheap to render and changes on every request. There, a plain no-cache and a fast origin beat a stale window you have to keep reasoning about, and that is the trade-off we weigh on most speed optimisation engagements before touching a single header.