Implementing stale-while-revalidate on Cloudflare
Teams set stale-while-revalidate on an origin response expecting the same behavior they’d get from Fastly or Varnish — an instant stale delivery at expiry with a background refresh — and then find Cloudflare doing something different: a synchronous origin fetch that blocks the first unlucky request after TTL expiry, with a visible latency spike in monitoring. This happens because Cloudflare’s support for the directive has historically been inconsistent across plans and has evolved over time, so getting the grace-window behavior you actually want on Cloudflare means being explicit about edge TTL, verifying with CF-Cache-Status, and knowing when to fall back to a Workers-based implementation.
Prerequisite Concepts
- Serving Stale Content with stale-while-revalidate — the RFC 5861 mechanism, the grace-window math, and how
must-revalidatedisables it entirely. - Conditional Requests and 304 Responses — the background revalidation this page configures is itself a conditional request.
- Interpreting X-Cache and CF-Cache-Status Headers — the baseline meaning of the status values used throughout verification here.
Step-by-Step Resolution
Step 1 — Understand Cloudflare’s historical support gap
Unlike Fastly, which reads stale-while-revalidate and stale-if-error directly out of the origin’s Cache-Control header by default, Cloudflare’s edge has not uniformly applied the same parsing across all plans and product surfaces. An object past its edge TTL has often simply been treated as EXPIRED — a plain miss — with the next request paying the full synchronous origin round-trip, rather than getting an immediate stale response plus a background refresh. Cloudflare’s product surface for stale-serving (Cache Rules, Cache Reserve, tiered caching) has changed over successive releases, so the correct approach is to configure explicitly and verify empirically against your own zone rather than trust the directive alone to produce RFC 5861 behavior.
This gap matters most for traffic patterns where a popular object expires and a burst of concurrent requests lands at once — the classic thundering-herd moment that stale-while-revalidate exists to smooth over. On a CDN that honors the directive natively, that burst is served instantly from the stale copy while one background fetch refreshes it. On a Cloudflare zone that instead falls back to treating expiry as a plain miss, every request in that burst can independently reach origin at once unless request collapsing is also in effect, turning a routine TTL rollover into a load spike. That’s the concrete failure mode the rest of this page is walking through how to avoid — first by configuring the edge correctly, then by verifying the actual header behavior rather than trusting the configuration screen, and finally by falling back to a Worker for the paths where the edge still won’t cooperate.
Step 2 — Set CDN-Cache-Control independently of Cache-Control
CDN-Cache-Control is a multi-CDN-recognized header (also honored by several other providers) that lets you control edge caching behavior separately from what you send browsers in Cache-Control. This matters here because browser support for stale-while-revalidate at the HTTP cache layer is unreliable anyway, so there is little reason to advertise it to clients — you want the edge to have its own instructions:
Cache-Control: public, max-age=60
CDN-Cache-Control: max-age=600, stale-while-revalidate=120
Browsers see a conservative one-minute freshness lifetime. The edge, reading CDN-Cache-Control, uses a ten-minute lifetime with a two-minute grace window layered on top — the split lets you tune each tier independently instead of forcing one Cache-Control value to serve both audiences.
Step 3 — Configure a Cache Rule for edge TTL
In the Cloudflare dashboard, go to Caching → Cache Rules and create a rule matching the relevant path or hostname. Set an explicit Edge Cache TTL rather than relying on “Respect origin headers,” since that gives you a known, testable expiry point to verify against in Step 4. Note that an explicit numeric stale-serving window is not a first-class toggle on every plan tier — where your zone doesn’t expose one, the Edge TTL you set here still defines the boundary the Worker fallback in Step 5 measures against.
Rule name: api-edge-ttl
When incoming requests match: URI Path starts_with "/api/products"
Then:
Edge Cache TTL: 10 minutes
Browser Cache TTL: Respect Origin (honors max-age=60 from Step 2)
Step 4 — Verify with curl and CF-Cache-Status
# First request — cold, expect MISS
curl -sI https://example.com/api/products/9472 \
| grep -iE 'cf-cache-status|age|cache-control|cdn-cache-control'
Expected output:
CF-Cache-Status: MISS
Cache-Control: public, max-age=60
Age: 0
# Second request, still within the 10-minute edge TTL — expect HIT
curl -sI https://example.com/api/products/9472 \
| grep -iE 'cf-cache-status|age'
CF-Cache-Status: HIT
Age: 12
# Request made just after the 10-minute edge TTL boundary
curl -sI https://example.com/api/products/9472 \
| grep -iE 'cf-cache-status|age'
CF-Cache-Status: STALE
Age: 601
STALE here confirms the edge served the previous object while triggering (or continuing) a background refresh, rather than falling back to a plain EXPIRED miss that blocks the client. If your zone instead returns EXPIRED at this point, the edge is not honoring the grace window for that path and you should move to the Workers fallback in Step 5.
# A request shortly after, once the background refresh has completed
curl -sI https://example.com/api/products/9472 \
| grep -iE 'cf-cache-status|age'
CF-Cache-Status: REVALIDATED
Age: 3
REVALIDATED confirms the background fetch completed and the entry was refreshed (or confirmed unchanged via a 304 from origin), with Age resetting to reflect the new object.
Step 5 — Add a Workers-based fallback
Where your plan or zone configuration doesn’t produce the STALE → REVALIDATED sequence above, implement the grace window explicitly in a Worker using the Cache API:
export default {
async fetch(request, env, ctx) {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
const cached = await cache.match(cacheKey);
const FRESH_MS = 60_000; // mirrors max-age=60
const STALE_MS = 120_000; // mirrors stale-while-revalidate=120
if (cached) {
const storedAt = Number(cached.headers.get("X-Cached-At") || 0);
const age = Date.now() - storedAt;
if (age <= FRESH_MS) {
return cached; // still fresh
}
if (age <= FRESH_MS + STALE_MS) {
// Serve stale immediately; refresh in the background
ctx.waitUntil(refreshCache(request, cacheKey, cache));
return cached;
}
// else: fall through to a synchronous refresh, hard-stale territory
}
return refreshCache(request, cacheKey, cache);
},
};
async function refreshCache(request, cacheKey, cache) {
const originResponse = await fetch(request);
const response = new Response(originResponse.body, originResponse);
response.headers.set("X-Cached-At", Date.now().toString());
response.headers.append("Cache-Control", "public, max-age=60");
await cache.put(cacheKey, response.clone());
return response;
}
ctx.waitUntil lets the Worker return the stale cached response to the client immediately while the refresh finishes after the response has already been sent — the same asynchronous-revalidation shape described for the general mechanism. Keep in mind caches.default is scoped per colocation facility, not globally shared across Cloudflare’s network, so different edge locations can be in slightly different points of their own grace window at the same wall-clock time. That’s an acceptable trade-off for most traffic patterns; it is not equivalent to a single global cache state.
Expected Output / Verification
A correctly behaving configuration shows this CF-Cache-Status sequence across a request lifecycle:
Request 1 (cold): CF-Cache-Status: MISS
Request 2 (within edge TTL): CF-Cache-Status: HIT, Age: rising
Request 3 (just past edge TTL): CF-Cache-Status: STALE, Age: > configured TTL
Request 4 (after refresh): CF-Cache-Status: REVALIDATED, Age: reset to near 0
For the Workers fallback, check the X-Cached-At header (or strip it before it reaches the client and log it separately) to confirm the served object’s age lines up with the FRESH_MS / STALE_MS boundaries you configured, and confirm via Worker logs or Tail that refreshCache actually fired during the grace window rather than only on hard misses.
Edge Cases
- Tiered caching and upper-tier divergence — with tiered caching enabled, an edge PoP and its upper-tier colo can hold the object at slightly different ages, so one PoP may show
STALEwhile a nearby one still showsHIT. Test from multiple geographic vantage points before concluding the configuration is broken. - Cache Reserve changes persistence, not the status sequence — Cache Reserve affects how long an object survives at the edge before full eviction; it does not by itself guarantee a
STALEstatus appears instead ofEXPIREDfor a given plan. - Plan-tier differences in which status values surface — not every plan exposes the same set of
CF-Cache-Statusvalues for the same underlying behavior. Confirm against your own zone’s dashboard and response headers rather than assuming parity with Enterprise documentation. - Workers cache fragmentation across colos — because
caches.defaultis per-colocation-facility, a purge or manual cache clear needs to account for the fact that the fallback’s state isn’t a single global entry the way aCache-Tagpurge is for CDN-native storage. - Development mode and cache bypass — Cloudflare’s Development Mode bypasses the cache entirely for up to three hours per zone, which will make every request look like a
MISSorDYNAMICregardless of your Cache Rules or Worker logic. Confirm Development Mode is off before concluding aSTALE/REVALIDATEDsequence is failing to appear. - Page Rules versus Cache Rules precedence — legacy Page Rules that set a “Cache Level” or “Edge Cache TTL” for the same path can override or conflict with a newer Cache Rule, producing a TTL that doesn’t match what you configured in Step 3. Audit for legacy Page Rules on any path you’re debugging.
Frequently Asked Questions
Does Cloudflare support stale-while-revalidate natively today?
It depends on plan and configuration, and this has changed across product versions. Rather than assuming compliance from the directive alone, verify with the CF-Cache-Status sequence described in Step 4 against your own zone.
What is the difference between EXPIRED and STALE CF-Cache-Status values?
EXPIRED generally means the edge treated a past-TTL object as a plain miss requiring a synchronous origin fetch. STALE means the edge served the old object while a revalidation resolved in the background — the behavior stale-while-revalidate is meant to produce. Which one you see depends on plan tier and Cache Rules configuration.
Do I need Cache Reserve for this to work?
Not strictly. Cache Reserve and tiered caching affect how long objects persist and how consistently a stale entry remains available right at TTL expiry, but no single product guarantees RFC 5861 semantics by itself — test your specific configuration.
Can a Worker fully replace CDN-native stale-while-revalidate?
It reproduces the behavior per colocation facility rather than globally, since caches.default isn’t shared network-wide. It’s a solid fallback for specific routes that need it, not a wholesale substitute for native edge cache configuration where that’s available.
Related
- ETag Generation and Validation Strategies — the validator strategy that determines whether a background revalidation returns a cheap
304or a full body. - How to Debug 304 Not Modified Responses — troubleshooting steps when the background refresh isn’t producing the expected conditional result.
- Surrogate-Key vs Cache-Tag Across CDN Vendors — how tag-based purges interact with (and bypass) the stale-serving window described here.
- Purging Cloudflare Cache Tags with the API — the Cloudflare-specific purge mechanism that evicts an entry outright rather than marking it stale.