Cloudflare launches Workers Cache: caching moves in front of every Worker, hits don't cost CPU
- Cloudflare launched Workers Cache today, putting a tiered regional cache directly in front of every Worker — one line of Wrangler config turns it on.
- On a cache hit, the Worker doesn't run at all and isn't billed for CPU time; on a miss, it runs once and backfills both cache layers.
- The cache no longer lives on a zone (domain) — it travels with the Worker. workers.dev, preview environments, and Workers for Platforms all get it, and purging only affects your own entrypoint.
- The biggest leap: the cache can now sit between any two entrypoints inside the same Worker, and with ctx.props it lets authenticated per-user APIs share a cache safely.
- Available today for every Worker on every plan — no separate product, no extra line item.
Now the Worker itself can have a cache
Cloudflare today launched Workers Cache, a cache layer that sits directly in front of your Worker. Turning it on takes one added line of config. Controlling it uses the standard HTTP cache headers you already know.
Why it matters: per Cloudflare's own account, they haven't seen another platform embed a cache inside a single deployment unit, switchable per entrypoint — nor another CDN turn "safe shared caching for logged-in-user APIs" into a built-in capability.
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"cache": {
"enabled": true
}
}
Once it's on, how things get cached goes back to the way HTTP has always worked: set headers on the response.
return new Response(body, {
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Cache-Tag": "products,product:123",
},
});
await ctx.cache.purge({ tags: ["product:123"] });
That's the whole API. No zone to configure, no rules engine to build, no separate cache product to provision, no second product to log into. The Worker's code is the config surface, and the cache follows the Worker wherever it runs: custom domains, workers.dev, behind a service binding, preview environments, inside a Workers for Platforms tenant — it's all one Worker, one cache, configured once.
The Worker used to stand in front of the cache — now it is the origin
When Cloudflare launched Workers back in 2017, the pitch was running your code at the network edge to modify a request on its way to the origin. Back then, the Worker sat in front of both the cache and the origin.
The origin is the actual server that holds your site's content and generates the pages. With the Worker in front of it, you could add headers, rewrite URLs, run A/B splits, filter traffic before it ever reached the origin. For the use cases of that era, that position made sense — you had full control over what got cached and what didn't.
Then things changed. Frameworks like Astro, TanStack Start, Next.js, Remix, and SvelteKit all ship Cloudflare adapters that bundle the entire application into a single Worker. There's no origin behind them — the Worker itself is the server.
When the Worker is the origin, the old architecture with cache behind it has nothing left to cache. Every request re-runs the code, even if the response is byte-for-byte identical to the one from a second ago. The Workers runtime is fast enough to render fresh on every request, handling tens of millions of requests a second without breaking a sweat — but "fast enough to render every time" still comes with two bills: latency on every page load, and CPU time on every execution. And for a server-rendered app, every page load is by definition a render.
Until now, you only had two uncomfortable options to pick from:
Workers Cache offers a third path: render server-side on demand, cache the result, refresh it on whatever TTL you choose. The first request for a new page still renders; after that, until the cache expires, every request gets served as if it were a static page. You get the speed of a static site without the build step, and the freshness of server-side rendering without the ongoing cost.
The cache flips around — now it stands in front of the Worker
Workers Cache is, by default, a two-tier regional cache — and this topology kicks in automatically, no configuration needed. Here's how a request decides which layer to check, and when your code finally gets bothered:
A convenience store chain restocking: check your own store's shelves first (lower-tier cache); if it's not there, call the regional warehouse (upper-tier cache); if the warehouse doesn't have it either, order fresh from the factory (run the Worker). Once the factory makes a batch, both the shelf and the warehouse get restocked — nobody else has to bother the factory again.
This topology is the same one zones already get with Tiered Cache — the difference is you don't have to configure it. There's no "turn on tiering for my Worker" switch. As long as the Worker has caching enabled, tiering comes free. If your Worker uses Smart Placement, the cache stacks cleanly with it too: both tiers are checked first, and only if neither hits does Smart Placement route execution to wherever the data lives.
Nobody waits around at the moment the cache expires
stale-while-revalidate tells Cloudflare: once the cache expires, it's fine to hand the user the old copy immediately while quietly re-running the Worker in the background to get a fresh one. This one directive is what turns "we'll cache your Worker for you" into "your Worker-backed site feels like a static site."
Without it, the first request after expiry has to wait for the Worker to render from scratch — a delay users can actually feel. With it, the first request after expiry gets the old page instantly (with a Cf-Cache-Status: UPDATING header), while the Worker runs in the background to refill the cache. Every user, including the one who triggered the refresh, gets a cache-speed response.
You set the windows yourself, matched to how often your content actually changes:
stale-while-revalidate=3600
stale-while-revalidate=2592000
Only the very first request for a brand-new page pays the full render cost. After that, from a visitor's point of view, the page behaves exactly like static output — while your Worker still decides how the page gets generated.
This cache follows the Worker, not the domain
Cloudflare has always had caching, but it lived on the zone (the config unit tied to a domain): Cache Rules, Page Rules, the cacheable file-extension list, Cache Reserve, tiered-cache topology, custom cache keys — all configured on the zone. In the past, a Worker either had to work around whatever the zone's config did, or route around it.
Workers Cache changes the ownership: it's your Worker's cache, owned by the Worker, not by some zone. That brings a few concrete benefits:
- No zone config to manage. Cache Rules, cache-level settings, file-extension lists, Page Rules — none of them apply to Workers Cache. The Worker's Cache-Control headers are the entire configuration.
- The cache follows the Worker, not the hostname. A Worker bound to api.example.com, api.example.net, and also invoked via a service binding shares one cache across all three paths. A request for /users/42, no matter which path it arrives on, hits the same cache entry.
- Works on workers.dev too. Preview environments each get their own independent cache, so testing a change doesn't pollute production; each tenant Worker in Workers for Platforms caches independently, with no cross-tenant interference. These used to be second-class citizens for caching — not anymore.
- Purging only affects your own entrypoint. Calling purge only clears your Worker's own entrypoint cache — it won't accidentally wipe out other content on the zone, and one Worker's deploy won't clear another's data.
Whatever caching behavior you want, it's written in code: give some paths a longer TTL by branching max-age per path; bypass caching for certain requests by returning Cache-Control: private; control how the cache key is computed by controlling what goes into ctx.props, or by normalizing the URL in a gateway Worker. The Worker you've already written is the config surface.
The cache can slot into any seam inside a Worker
Workers Cache sits in front of every Worker entrypoint: the default export, every named entrypoint, and even the call where one entrypoint inside the same Worker invokes another via ctx.exports. That last one changes what you can build.
A single Worker program can be split into several independent "entrypoints" that call each other directly in code, without actually firing off a network request and looping back around. When one entrypoint calls another via ctx.exports, the cache evaluates that call just like it would a browser request.
A company where the front desk (gateway entrypoint) takes a visitor's request and hands it straight to the right internal department (another entrypoint) — the visitor never has to walk across the street to another building. The cache sits between the front desk and the department: if the department's last answer is still on file, the front desk hands it straight to the visitor, and the department doesn't even open its door this time.
A hit returns the cached response — the called entrypoint doesn't run at all; a miss runs it once and stores a cache entry keyed by its own entrypoint, path, query string, and ctx.props. The caller always runs, but the work it hands off gets cached independently. You decide entrypoint by entrypoint who gets cached: wrangler's exports section lets you toggle each one separately. Entrypoints doing authentication, normalization, and dispatch — like a gateway or router — should have caching turned off, so they always run and their own output is never served from cache.
How caching is configured is entirely expressed as ordinary Worker code: which entrypoint you call, what request you forward, what ctx.props you pass, what Cache-Control you set. The Worker below does three things at once that aren't easy to combine on other platforms: authenticate every request, cache an expensive backend behind a multi-tenant-safe cache key, and purge that cache when the underlying data changes. Caching is configured per entrypoint — the gateway has to run every time (for auth, and also because caching it would let requests skip that auth check), so the default entrypoint has caching off, and only the internal entrypoint has it on:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"cache": { "enabled": true },
"exports": {
// The gateway must run on every request — don't cache it
"default": { "type": "worker", "cache": { "enabled": false } },
// Cache the expensive internal entrypoint
"CachedBackend": { "type": "worker", "cache": { "enabled": true } }
}
}
Expand the full code for this one Worker (gateway + cached backend + tag-based invalidation)
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env { API_TOKEN: string; }
interface Props { userId: string; }
// Internal entrypoint: the expensive work. The cache sits in front of it,
// so this code doesn't run at all on a hit.
export class CachedBackend extends WorkerEntrypoint<Env, Props> {
async fetch(request: Request): Promise<Response> {
// ctx.props.userId is part of the cache key, so each user gets a
// separate cache entry.
const { userId } = this.ctx.props;
const data = await loadExpensiveData(userId);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Cache-Tag": `user:${userId}`,
},
});
}
// Purge a given user's cache. Purge scope is tied to the entrypoint that
// calls it, so this must run inside CachedBackend, which owns the cache.
async invalidate(userId: string): Promise<void> {
await this.ctx.cache.purge({ tags: [`user:${userId}`] });
}
}
// Outer entrypoint: runs on every request, handles auth and routing.
// Caching is disabled for it in wrangler, so it always runs and auth
// never gets skipped by a cache hit.
export default {
async fetch(request, env, ctx): Promise<Response> {
const userId = await authenticate(request, env);
if (!userId) return new Response("Unauthorized", { status: 401 });
// On writes, purge this user's cache from the entrypoint that owns it.
if (request.method === "POST") {
await handleWrite(request, userId);
await ctx.exports.CachedBackend.invalidate(userId);
return new Response("OK");
}
// Reads: strip Authorization first (otherwise Cloudflare auto-bypasses
// the cache and nothing gets cached at all), then forward the
// authenticated user identity via ctx.props to the cached backend.
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
return ctx.exports.CachedBackend.fetch(forwarded, {
props: { userId },
});
},
} satisfies ExportedHandler<Env>;
The whole thing is one Worker, one source file, one deployment. But it has two execution stages, and a small exports block turns caching off for the gateway and on for the backend, with the cache sitting between them — keyed per user, purged by tag on writes, serving stale content while it refreshes in the background. This caching stage isn't bolted on; it's a layer of the program, written in code.
The same shape applies elsewhere: wrap a Durable Object behind an entrypoint, skip it entirely on a read hit, go straight to the Durable Object on writes and purge the cache by tag; strip tracking parameters (?utm_source=…) in the gateway before forwarding, so the cache only ever sees a clean URL and every variant of a link collapses to the same cache entry. These entrypoints can be layered on top of each other, and the caching stage between any two of them isn't something you configure — it's just something you decide where to place.
Logged-in-user APIs can safely share a cache too
If you're caching an endpoint that returns different content per user — say, an API that returns each logged-in user's own data — you need to guarantee one user never sees another user's cached response. The old approach was "never cache authenticated requests," and Cloudflare bypasses caching by default for any request carrying an Authorization header. But "never cache anything" throws away the entire performance win.
Workers Cache solves this by folding the caller's ctx.props into the cache key. Every cached request gets an invisible "who is this" tag; the same URL, requested by different users, gets stored as separate cache copies — user A's data is never returned as a cached response to user B.
export default class Backend extends WorkerEntrypoint<Env, Props> {
async fetch(request: Request): Promise<Response> {
// ctx.props.userId is part of the cache key. Users A and B requesting
// the same URL get separate, independent cache entries.
const { userId } = this.ctx.props;
const data = await loadUserData(userId);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
});
}
}
The typical pattern: authenticate the request in a gateway Worker, strip the Authorization header, write the authenticated user ID into ctx.props, then call the cached backend Worker. The gateway runs on every request (it has to, to authenticate), but the expensive backend only runs when this user doesn't already have a cache entry. An API that used to be "uncacheable because it's authenticated" becomes "cached per user, with full security isolation" — and the isolation is handled for you by the cache key. Per Cloudflare, other CDNs force you to choose between "correct" and "high hit rate," and they haven't seen anyone else build this in as a native capability.
This also happens to produce a "close to the user + close to the data" two-tier architecture
There's a long-standing tension in web performance: you want your code to run close to the user (the user-to-server round trip is on the critical path), but you also want it to run close to the data (every database query is another round trip). Pick one, and the other gets slower. Cloudflare's network already reaches roughly 95% of the world's internet population within about 50ms; pair that with Smart Placement, and code can run right next to the data too. The missing piece was exactly this caching layer.
You don't need to design a special architecture to get this — just split your app into two Workers, point one at the other with a service binding, and turn on caching in Worker B's wrangler config. That's it.
How content negotiation, the observability dashboard, and billing work
Real applications rarely return the exact same bytes to every client. The same product page needs to return HTML to a browser and JSON to an API client; the same image needs WebP for clients that support it and JPEG for those that don't; the same homepage might return Chinese, French, or Japanese depending on the user.
Multiple versions of one URL: the Vary header
This is easy without caching — the Worker just reads a request header and returns the right thing. It gets hard once caching enters the picture. Most caches give you two bad options: don't cache URLs that have multiple versions, or cache just one version and serve it to everyone. Workers Cache supports the standard Vary header: return Vary: Accept, and Cloudflare stores a separate variant per distinct Accept value, returning only the one that matches the incoming request. There's no allowlist for what you can put in Vary — whatever you list, Cloudflare stores variants keyed on that value as-is. One URL, two cached variants, the Worker writes each one once and then runs zero times for anyone after that.
Cache and Worker metrics live on the same dashboard
The Workers Observability dashboard now shows cache info per invocation. You can see hit-rate trends over time per Worker, plus a breakdown of HIT, MISS, UPDATING, and BYPASS. When hit rate is low, this is where you go to find out why: too much BYPASS might mean something is setting cookies; too much MISS might mean the cache key is more fragmented than you thought; too much UPDATING might mean max-age is shorter than your traffic interval. All of this sits on the same dashboard as the Worker's logs, exceptions, CPU time, and request counts — no switching back and forth.
Billing: hits skip CPU, but two scenarios now incur standard request fees
A cache hit doesn't run the Worker and isn't billed for CPU time, but it still counts as a request, billed at the standard Workers request rate — same as any other invocation. Misses and bypasses are billed normally: request fee plus CPU time.
| Scenario | Request fee | CPU time fee |
|---|---|---|
| Cache HIT (Worker doesn't run) | Standard rate | Not billed |
| Cache MISS (Worker runs) | Standard rate | Billed |
| Cache BYPASS (Worker runs) | Standard rate | Billed |
| Static asset request | Standard rate | Not billed |
| Worker-to-Worker call | Standard rate | Billed only if it runs |
There's no separate line item for Workers Cache, and no per-GB storage fee for the cache. Tiered caching, purging, stale-while-revalidate, and all of the analytics above are included. A request that would have run the Worker, served instead as a hit, still costs you the standard request fee — but skips the CPU time, so it's cheaper than rendering it fresh in the Worker. One thing worth noting: once caching is turned on, static asset requests that used to be free, along with Worker-to-Worker calls via a service binding or ctx.exports, now get billed at the standard request rate, because each of them now has to check the cache in front of it first.
Available today, still being refined
Workers Cache is available today for every Worker on every plan, enabled through Wrangler. Getting started takes three steps: add "cache": { "enabled": true } to wrangler.jsonc, redeploy, and start setting Cache-Control headers on your responses.
Cloudflare has also listed what's still in progress:
- Tighter coordination between Smart Placement and caching. Right now the upper-tier cache target and the Smart Placement target are chosen independently, so a full miss can bounce a request across two Cloudflare datacenters: one to check the upper-tier cache, another to run the Worker near the data. They're working on coordinating the two so a miss only makes one long-haul trip.
- Raising the size cap. At launch, every response shares the free plan's 512MB cacheable size cap, regardless of account. This is temporary and will be split by plan after a few rollout steps.
- More framework adapters. Astro already has built-in Workers Cache support, so server-rendered pages automatically flow through "render once, cache, refresh in the background." TanStack Start and Next.js (via Vinext) are also catching up.
- A new ctx.cache.invalidate(). purge deletes matching responses from the cache outright; invalidate will mark them "stale" instead, so the next request can still get a fast, stale response via stale-while-revalidate while the Worker refreshes in the background.
A Worker used to run in front of the cache. Now it can run behind it too. Use whichever side you need — or, with a service binding, both at once. — Cloudflare Blog, "Your Worker can now have its own cache in front of it"
The cache moved in front of the code: a page used to be recomputed on every visit → now it's computed once and stored — a hit means the code doesn't run and isn't billed
Cloudflare launched Workers Cache, one config line that adds an edge-server cache layer. This page, with a diagram, walks through how it saves time and money, and where the real breakthrough is.
↓ Read the whole page in one go · one diagram animates
Cloudflare Workers are small programs that run in datacenters worldwide on behalf of websites — close to visitors, so they're fast. In recent years, frameworks like Astro and Next.js started bundling entire websites into a single Worker; it's no longer a filter in front of a server, it is the server.
✘ But a computed page can't be stored: every visit re-runs the code from scratch to generate the same page
It is the source itself, with nothing cacheable behind it — even if the page is identical to one from a second ago, it still recomputes and still costs CPU
Cloudflare moved the cache in front of the Worker. Turning it on takes one line of config; controlling it uses standard HTTP cache headers. On a hit, the Worker doesn't run once and isn't billed for CPU; only on a miss does it run once, then stores the result in cache.
→ run code to render
→ return
(recomputed even if unchanged)
Cache-Control: max-age=300
A hit serves straight from cache — the Worker never runs.
The bigger payoff is flexibility: this cache isn't a bolt-on product but a layer written into the code — it can slot into any point in the site, even safely caching private endpoints that require login. The system tags each cache entry with "whose is this," so you store yours, they store theirs, and your order never gets served to someone else.
So how does the cache actually move between "we have it in stock" and "we don't"? The diagram below makes it clear.
The cache used to sit behind the Worker, unable to help. Now it's built as a checkpoint in front of it. Take XiaoHu's online store product page as an example: 100 visitors view the same page within 5 minutes.
Cloudflare didn't publish a "how many times faster" benchmark — what it actually saves is repeated computation. Translating that to the feel of XiaoHu's product page example:
same page.
recomputes the page from scratch.
gets billed for CPU
why compute it 100 times?!
in front of the Worker.
standing at the very front —
nothing behind it to cache.
now stands in front of the Worker.
how does it actually flow?!
Worker runs once,
stores the page in cache.
compute fresh.
served straight out, instantly,
Worker never runs once.
keep it
even login-required private endpoints get their own safe cache, no cross-wiring.
