← /blog

Cloudflare does not edge-cache your Worker's HTML. Here is the fix.

I had Cache-Control: public, max-age=0, s-maxage=300 on every public page of this site and assumed Cloudflare was caching them. It was not. This is how I found out, and the small fix that made repeat requests about six times faster.

The symptom

The site is Astro SSR on a single Cloudflare Worker. Static assets are cached at the edge automatically, and /og-default.png comes back with CF-Cache-Status: HIT. But /experience, which is rendered by the Worker, returned no cf-cache-status header at all, and requesting it twice was no faster than requesting it once.

The reason: Cloudflare caches static assets by default, but it does not cache responses that a Worker generates, whatever Cache-Control says. s-maxage only matters once something has decided the response is cacheable.

There are two ways to make that decision. You can add a Cache Rule in the dashboard that marks the paths as eligible for cache. Or you can do it yourself inside the Worker with the Cache API. I went with the second one, because it keeps the behaviour in version control next to the code that depends on it.

The fix

In the Astro middleware, before rendering, look the URL up in caches.default. On a hit, return it. On a miss, render as usual, then store a copy of the response in the background:

const edgeCache = isGet && !isLocal && isCacheablePublicPath(url.pathname)
  ? (caches as any).default
  : undefined;
const cacheKey = new Request(url.toString(), { method: 'GET' });

if (edgeCache) {
  const hit = await edgeCache.match(cacheKey);
  if (hit) {
    const out = new Response(hit.body, hit);
    out.headers.set('x-edge-cache', 'HIT');
    return out;
  }
}

const res = await next();

if (edgeCache && res.status === 200 && !res.headers.has('set-cookie')) {
  const headers = new Headers(res.clone().headers);
  headers.set('cache-control', 'public, max-age=60');
  ctx.locals.cfContext.waitUntil(
    edgeCache.put(cacheKey, new Response(res.clone().body, { status: 200, headers })),
  );
  res.headers.set('x-edge-cache', 'MISS');
}
return res;

A few details matter more than the code:

  • Only cache what is safe. 200 responses, GET only, no Set-Cookie, and only for an allow-list of public paths. The admin panel, the API and the MCP JSON-RPC endpoint are not on the list, so they never touch the cache.
  • Store with a short lifetime. The stored copy gets max-age=60. Edits made in the admin panel show up on public pages within a minute, and I did not need to add purge calls to every write handler. Browsers and the CDN still see the longer s-maxage on the response that is actually served.
  • Never let the cache break a page. The lookup is wrapped so that a cache error falls through to a normal render.
  • Expose the result. The x-edge-cache: HIT or MISS header makes it easy to verify.

The result

On the custom domain, /experience went from about 0.67 seconds to first byte uncached to about 0.11 seconds on a cache hit. The first request returns MISS, the following ones return HIT, and /admin, /api and /mcp/rpc are untouched.

Gotchas

  • It only works on your own domain. The Cache API does nothing on *.workers.dev, and it does nothing under wrangler dev. I guard on a local hostname check, but you have to verify on the real custom domain.
  • It is per data center. Each Cloudflare location has its own cache, so the first visitor in each region pays the miss.
  • Existing purge code may have been deleting nothing. This site already had a helper that called caches.default.delete after blog changes. Since nothing had ever put entries there, it had been purging an empty cache. Once the middleware started storing pages, the same helper began to do something.
  • A Cache Rule is still an option. If you would rather manage this in the dashboard, add a rule that is eligible for cache, and keep admin, API and RPC paths out of it. Doing both gives you two layers and makes it harder to tell which one served a response.

When I would not do this

If a page depends on the visitor, such as a signed-in view, a cart or anything cookie-driven, do not put it behind a shared cache without a very deliberate key. For a personal site where every public page is rendered from the database and a few typed files, a 60 second cache is an easy win.