Hello.

tip

Workers Cache answers cache hits without running your Worker

2026-11-14

TL;DR With cache.enabled in the wrangler config, Cloudflare answers a cache hit without running the Worker, so a hit costs no CPU time. Tag responses with Cache-Tag and purge with cache.purge({ tags }) from the Worker; zone-level purges do not touch this cache.

A Worker behind the Cache API runs on every request, even on a hit: about 1.4 ms of CPU per hit and 6 to 21 ms per warm miss on one data center. Every data center also paid for its own miss. I wanted to know whether hits can skip the Worker, and decided to migrate.

Claude Code did the research and wrote the code changes; I ran the deploy.

A hit never reaches the Worker

Request path with Workers Cache

Only these two methods are cached. Everything else runs the Worker.

1 / 5

Because of the upper tier, one fill benefits every data center. A hit is still billed as a request, but not for CPU time. The cache key is the path, the query string and the Worker version, so every deploy starts with an empty cache. Nothing is cached before it has been served once, because there is no pre-warming.

The Worker only declares what may be stored

The code that used to look up and store pages in caches.default goes away. What stays is the part that sets headers on a successful page (excerpt):

typescript
const res = await next();
const out = new Response(res.body, res);
out.headers.set('Cache-Control', 'public, max-age=0, must-revalidate, s-maxage=3600');
out.headers.set('Cache-Tag', tags.join(','));
return out;

max-age=0 keeps browsers revalidating, s-maxage=3600 is the TTL for Cloudflare. Cache-Tag is a comma-separated list of printable ASCII, and Cloudflare strips it before the response reaches the client. Responses with Set-Cookie, private or no-store bypass the cache, which is the right default for error pages: send no-store when a CMS call failed while rendering.

One catch: s-maxage and must-revalidate switch off stale-while-revalidate. For that, set max-age instead, or use cloudflare-cdn-cache-control to give only the edge a TTL.

A hit shows up in the response headers

I deployed it and requested the home page three times:

First request misses, the next ones hit
$ curl -s -D - -o /dev/null https://blog.mikepenz.dev/ | grep -i -E '^(HTTP|cf-cache-status|age|cache-control)'
HTTP/2 200
cf-cache-status: MISS
cache-control: public, max-age=0, must-revalidate, s-maxage=3600
$ curl -s -D - -o /dev/null https://blog.mikepenz.dev/ | grep -i -E '^(HTTP|cf-cache-status|age|cache-control)'
HTTP/2 200
cf-cache-status: HIT
age: 1
cache-control: public, max-age=0, must-revalidate, s-maxage=3600
$ curl -s -D - -o /dev/null https://blog.mikepenz.dev/ | grep -i -E '^(HTTP|cf-cache-status|age|cache-control)'
HTTP/2 200
cf-cache-status: HIT
age: 2
cache-control: public, max-age=0, must-revalidate, s-maxage=3600

age counts seconds since the page was stored. Nothing in the Worker marks these hits, so cf-cache-status is the only signal.

The Worker purges its own cache by tag

typescript
import { cache } from 'cloudflare:workers';

await cache.purge({ tags: ['post-my-slug', 'index', 'feed'] });

ctx.cache.purge does the same. The CMS publish webhook calls this, so there is no zone id and no purge API token to store. After a webhook replay for one post, the home page was a miss once and a hit again on the next request. Purges are scoped to the Worker entrypoint that calls them, and they use Free-tier rate limits on every plan.

The reverse is the trap: a purge from the dashboard, the API or Terraform does not affect Workers Cache, and neither do Cache Rules. The Cache API (caches.default) is a separate store as well.

Try it yourself

Add the block to your wrangler config. It needs wrangler 4.69.0 or newer.

Step 1 of 3

Enable the cache

Add this next to name and main, then deploy.

{
  "cache": { "enabled": true }
}

Honest limits

wrangler dev does not emulate Workers Cache. In three local requests every one ran the Worker and no cf-cache-status header appeared, so a hit shows up only after you deploy. Hit rates across data centers and over a day of traffic are untested.

Other limits from the docs: only GET and HEAD are cached, there is no cache pre-warming, a deploy starts empty unless you set cross_version_cache (and then a purge must evict stale pages), and at launch response size is capped at the Free plan limit. A Worker that checks authentication in its own code must not enable the cache, because a hit skips that check. I deploy the draft-preview Worker from a copy of the config without the cache block, and it answers 403 without the editor token.

After your first deploy, run the two curl commands and check for cf-cache-status: HIT on the second before you delete the old lookup code.