tip
Workers Cache answers cache hits without running your Worker
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.
The cache near the visitor. A hit is returned here, no Worker code runs.
Shared between data centers. A lower-tier miss asks it before the Worker is invoked.
Runs on a miss in both tiers. Concurrent misses for one key in one data center collapse into a single run.
The response is stored in both tiers, as long as it carries cacheable headers.
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):
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:
$ 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
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 }
}Send cacheable headers
Return the Cache-Control and Cache-Tag headers from the section above on your 200 pages.
Request twice
The first request is a miss, the second inside the TTL should be a hit.
curl -sI https://<your-domain>/ | grep -i -E 'cf-cache-status|cache-control|age'
curl -sI https://<your-domain>/ | grep -i -E 'cf-cache-status|cache-control|age'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.