Skip to content

Instantly share code, notes, and snippets.

@jwdeane
Last active June 19, 2026 01:00
Show Gist options
  • Select an option

  • Save jwdeane/ffabdd6497c7ffe6ec5ac9ad5020ed76 to your computer and use it in GitHub Desktop.

Select an option

Save jwdeane/ffabdd6497c7ffe6ec5ac9ad5020ed76 to your computer and use it in GitHub Desktop.
Cloudflare Workers stale-while-revalidate implementations

Stale-While-Revalidate Implementations

This project can demonstrate stale-while-revalidate (SWR) in two different ways:

  1. Native Cloudflare CDN cache behavior.
  2. A custom Worker implementation using the Workers Cache API.

They look similar from the client side, but the cache layer doing the work is different.

1. Native CDN SWR

Native CDN SWR relies on Cloudflare's normal CDN cache interpreting HTTP cache directives from the origin response.

Example response header:

Cache-Control: max-age=5, stale-while-revalidate=60

Semantics:

  • 0–5s: response is fresh and can be served from cache.
  • 5–65s: response is stale but may be served immediately while Cloudflare revalidates in the background.
  • >65s: response is too stale and must be refreshed before serving.

Requirements

The fetched URL must be handled by Cloudflare's CDN cache for the zone whose cache policy should apply.

For a Worker subrequest:

await fetch("https://api.example.com/mix/s=200/d=3");

Cloudflare applies cache rules based on the subrequest URL (api.example.com), not the original visitor URL that invoked the Worker.

If api.example.com is another active/proxied Cloudflare zone, that zone's cache configuration applies.

Cache Response Rules

If the origin does not emit the desired header, a Cache Response Rule can modify the origin response before it is cached, for example setting:

Cache-Control: max-age=5, stale-while-revalidate=60

Use this when you want Cloudflare's CDN cache to perform SWR natively.

Important caveats

  • This does not work in wrangler dev / localhost:8787 because local development does not run zone-level Cache Rules or Cache Response Rules.
  • Cache Response Rules require the hostname to be proxied through Cloudflare.
  • Avoid cf.cacheTtl / Edge TTL overrides when testing native SWR, because Edge TTL-style overrides can disable or bypass revalidation directive behavior.
  • For cross-zone Worker subrequests, put the Cache Response Rule on the fetched hostname's zone, not necessarily the initiating Worker's zone.

Pros

  • Uses Cloudflare's CDN cache as intended.
  • Supports standard cache observability such as CF-Cache-Status.
  • Can use CDN-level features such as Tiered Cache.
  • Less Worker code.
  • Better for production when the cacheable asset is naturally an HTTP resource behind Cloudflare.

Cons

  • Requires a deployed/proxied Cloudflare zone.
  • Harder to demonstrate locally.
  • Behavior depends on zone configuration, cache eligibility, rules, and response headers.
  • Less explicit in application code.
  • Cross-zone behavior can be confusing: cache rules apply to the fetched URL's zone.

2. Custom Cache API SWR

The custom implementation uses caches.default as a storage layer and implements SWR logic directly in Worker code.

The Cache API does not natively support stale-while-revalidate or stale-if-error directives on cache.match() / cache.put().

Instead, the Worker stores metadata such as:

X-SWR-Stored-At: <timestamp>

Then it manually decides whether a cached response is fresh, stale-but-servable, or expired.

Example logic:

const cachedResponse = await cache.match(cacheKey);

if (cachedResponse) {
  const ageSeconds = getAgeSeconds(cachedResponse);

  if (ageSeconds <= MAX_AGE_SECONDS + STALE_WHILE_REVALIDATE_SECONDS) {
    if (ageSeconds > MAX_AGE_SECONDS) {
      ctx.waitUntil(refreshCache(cache, cacheKey));
    }

    return cachedResponse;
  }
}

return refreshCache(cache, cacheKey);

Semantics

With:

const MAX_AGE_SECONDS = 5;
const STALE_WHILE_REVALIDATE_SECONDS = 60;

The Worker enforces:

  • 0–5s: serve cached response as fresh.
  • 5–65s: serve cached response immediately and refresh in the background with ctx.waitUntil().
  • >65s: block on a new upstream response.

Why this works despite Cache API not supporting SWR

The Cache API is not interpreting the stale-while-revalidate directive.

The Worker code is doing the SWR decision-making itself. caches.default is only storing and retrieving responses.

So this does not contradict the documentation. The unsupported behavior is automatic SWR inside cache.match() / cache.put().

Internal TTL consideration

For a robust custom implementation, the response stored in Cache API should remain available for the whole stale window.

Conceptually:

Internal Cache API TTL: 65s
Client Cache-Control: max-age=5, stale-while-revalidate=60

Then the Worker code, not the Cache API, decides whether the entry is fresh or stale.

Pros

  • Works in local/demo-style Worker code without depending on zone-level Cache Response Rules.
  • Fully explicit application behavior.
  • Can cache generated Worker responses, not only origin responses.
  • Can implement custom policies beyond standard HTTP cache semantics.
  • Easy to add debugging headers such as X-Cache: HIT | MISS | STALE.

Cons

  • More code and more application responsibility.
  • Cache API SWR is manual, not native.
  • Cache API operations are local to the data center handling the request.
  • Cache API does not support Tiered Cache.
  • More care is needed around TTLs, cache keys, request methods, response headers, errors, and concurrent refreshes.

Does the Cache API variant lack Tiered Cache?

Yes.

Cloudflare documentation states that the Workers Cache API is not compatible with Tiered Cache. Cache API operations apply to the cache in the data center where the Worker is running.

That means:

  • cache.match() checks the local data center cache.
  • cache.put() stores in the local data center cache.
  • cache.delete() deletes from the local data center cache.

By contrast, fetch()-based CDN caching can use Cloudflare's normal CDN cache path and Tiered Cache when enabled.

Which implementation should be used?

Use native CDN SWR when:

  • The resource is an HTTP origin response behind Cloudflare.
  • You can configure the relevant zone's Cache Rules / Cache Response Rules.
  • You want CDN-level behavior, observability, and Tiered Cache.

Use custom Cache API SWR when:

  • You need local/demo behavior.
  • The response is generated or transformed by the Worker.
  • You need custom SWR behavior that is easier to express in code than in cache rules.
  • You accept that caching is local to each data center and does not use Tiered Cache.
const UPSTREAM_URL = "https://httpbun.com/mix/s=200/d=3/b64=aGVsbG8gd29ybGQ=";
const MAX_AGE_SECONDS = 5;
const STALE_WHILE_REVALIDATE_SECONDS = 60;
const CACHE_CONTROL = `max-age=${MAX_AGE_SECONDS}, stale-while-revalidate=${STALE_WHILE_REVALIDATE_SECONDS}`;
const STORED_AT_HEADER = 'X-SWR-Stored-At';
export default {
async fetch(request, env, ctx): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return fetchUpstream();
}
const cache = caches.default;
const cacheKey = new Request(request.url, request);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
const ageSeconds = getAgeSeconds(cachedResponse);
if (ageSeconds <= MAX_AGE_SECONDS + STALE_WHILE_REVALIDATE_SECONDS) {
if (ageSeconds > MAX_AGE_SECONDS) {
ctx.waitUntil(
refreshCache(cache, cacheKey).catch((error: unknown) => {
console.error('Failed to refresh stale cache entry', error);
}),
);
}
return toClientResponse(cachedResponse, ageSeconds > MAX_AGE_SECONDS ? 'STALE' : 'HIT');
}
}
return refreshCache(cache, cacheKey);
},
} satisfies ExportedHandler<Env>;
async function fetchUpstream(): Promise<Response> {
const upstreamResponse = await fetch(UPSTREAM_URL, {
cf: {
cacheTtl: MAX_AGE_SECONDS,
},
});
const response = new Response(upstreamResponse.body, upstreamResponse);
response.headers.set('Cache-Control', CACHE_CONTROL);
return response;
}
async function refreshCache(cache: Cache, cacheKey: Request): Promise<Response> {
const response = await fetchUpstream();
response.headers.set(STORED_AT_HEADER, Date.now().toString());
if (response.ok) {
await cache.put(cacheKey, response.clone());
}
return toClientResponse(response, 'MISS');
}
function getAgeSeconds(response: Response): number {
const storedAt = Number(response.headers.get(STORED_AT_HEADER));
if (!Number.isFinite(storedAt)) {
return Number.POSITIVE_INFINITY;
}
return (Date.now() - storedAt) / 1000;
}
function toClientResponse(response: Response, cacheStatus: 'HIT' | 'MISS' | 'STALE'): Response {
const clientResponse = new Response(response.body, response);
clientResponse.headers.delete(STORED_AT_HEADER);
clientResponse.headers.set('Cache-Control', CACHE_CONTROL);
clientResponse.headers.set('X-Cache', cacheStatus);
return clientResponse;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment