| [ Web Proxy ] |
| Viewing: https://developers.cloudflare.com/workers/cache/ | [Back] [Original] |
Workers Cache lets Cloudflare return cached HTTP responses from your Worker without executing your Worker code. When an incoming request matches a cached response, Cloudflare serves the response directly from its edge cache reducing latency and Workers CPU usage.
Caching works for any fetch() invocation of the Worker eyeball requests (requests from browsers and API clients), requests sent through service bindings, and loopback fetch() calls between entrypoints via ctx.exports. You control caching with standard HTTP Cache-Control directives on your responses.
Workers Cache is your Worker's cache. It is owned by your Worker, operated by your Worker, and private to your Worker.
A Worker is a zoneless entity a Worker can be bound to any number of zones, run on workers.dev, or be invoked entirely through service bindings without ever touching a zone. The cache follows the Worker, not a zone, so:
Cache-Control headers on your responses, and Cloudflare honors them per RFC 9111 . That is the entire configuration surface.api.example.com, api.example.net, and invoked over a service binding serves the same cached responses to all three the cache is keyed by the request path, entrypoint, ctx.props, and (by default) the Worker version, not by hostname. See Cache keys.A Worker is already infinitely customizable. You can change response bodies, rewrite headers, branch on any request attribute, call out to other Workers via service bindings or ctx.exports, and compose logic across an entire system.
Workers Caching leans on that. Instead of introducing a separate configuration layer for caching behavior, it lets your Worker express that intent directly through the Cache-Control headers it returns, the ctx.props it accepts, and the programmatic purges it issues. Anything you might want to configure about caching, you can configure in code:
max-age.ctx.props in a gateway Worker before dispatching.ctx.props that is in the cache key.Cache-Control: private, or rely on the automatic bypass triggered by Set-Cookie and Authorization.The Worker you already wrote is the configuration mechanism. Workers Caching runs in front of it and honors whatever headers the Worker returns.
Caching is a good fit for Workers that:
Caching is not useful for per-user responses that change on every request, non-idempotent operations (POST, PUT, DELETE), or responses that must be computed fresh every time.
With caching enabled, Cloudflare checks the cache before running your Worker. On a hit, the cached response is returned directly. On a miss, your Worker runs, and if the response is cacheable per its Cache-Control header, Cloudflare stores it for the next request.
flowchart LR
accTitle: Cache before a Worker request flow
accDescr: Request arrives at Cloudflare, cache is consulted before Worker execution.
Request["Request"] --> Cache{"Cache"}
Cache -- Hit --> Response["Cached response returned"]
Cache -- Miss --> Worker["Worker runs"]
Worker --> Store["Response stored in cache"]
Store --> Response2["Response returned"]
Workers Caching is tiered by default. Cloudflare operates two layers of cache for your Worker:
A request is served from the lower tier if it is a hit there. If it is a miss, the lower tier asks the upper tier. If the upper tier also misses, your Worker finally runs to generate the response and that response is stored in both tiers on the way back out, so subsequent requests from any data center benefit.
flowchart LR
accTitle: Tiered cache for Workers
accDescr: A request hits the lower-tier cache first, then the upper-tier cache, then the Worker.
Request["Request"] --> Lower{"Lower-tier cache<br/>(near eyeball)"}
Lower -- Hit --> Response["Cached response returned"]
Lower -- Miss --> Upper{"Upper-tier cache"}
Upper -- Hit --> Lower
Upper -- Miss --> Worker["Worker runs"]
Worker --> Upper
This is the same topology that powers Tiered Cache for zones, applied automatically to your Worker. You do not configure it, and the tiering runs regardless of whether your Worker uses Smart Placement.
Why this matters: the first request for a given cache key anywhere on Earth populates the upper tier. Every later request, from any Cloudflare data center, can be served from the upper tier without running your Worker even if the lower tier at that location has never seen the request before. Cache hit ratios are substantially higher than a single flat cache layer.
When many requests for the same cache key arrive simultaneously at a Cloudflare data center and the response is not yet cached, Cloudflare runs your Worker once and serves the resulting response to every waiting request. This is the same request collapsing mechanism the zone cache uses, applied automatically to Workers Caching. The waiting requests block on a per-cache-key cache lock until the first request produces a response.
flowchart LR
accTitle: Cache request collapsing for Workers
accDescr: Many simultaneous requests for the same cache key produce one Worker invocation; all requests receive the same response.
R1["Request 1"] --> Lock
R2["Request 2"] --> Lock
R3["Request 3"] --> Lock
Rn["..."] --> Lock
Lock{"Cache lock<br/>(per cache key, per data center)"}
Lock -- "first request" --> Worker["Worker runs once"]
Worker --> Response["Response<br/>streamed to all<br/>waiting requests"]
Why this matters: without request collapsing, a sudden burst of traffic to a fresh URL would invoke your Worker once per request, multiplying CPU billing and load on any backend the Worker calls. With request collapsing, that burst still produces one Worker invocation.
A few details to keep in mind:
BYPASS, DYNAMIC), each request gets its own invocation. The cache only collapses requests that produce a response the cache is allowed to store.This is one of the most significant differences between Workers Caching and the Cache API the Cache API does not collapse concurrent requests, so a burst of traffic to a fresh URL invokes your Worker once per request.
This quickstart walks you through enabling caching, deploying, and observing the cache in action.
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-19",
"cache": {
"enabled": true,
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-19"
[cache]
enabled = trueUse max-age to control how long Cloudflare caches each response:
export default {
async fetch(request) {
const body = JSON.stringify({
timestamp: new Date().toISOString(),
random: Math.random(),
});
return new Response(body, {
headers: {
"Content-Type": "application/json",
// Cache for 1 hour; serve stale for up to 5 minutes while revalidating.
"Cache-Control": "public, max-age=3600, stale-while-revalidate=300",
},
});
},
};export default {
async fetch(request): Promise<Response> {
const body = JSON.stringify({
timestamp: new Date().toISOString(),
random: Math.random(),
});
return new Response(body, {
headers: {
"Content-Type": "application/json",
// Cache for 1 hour; serve stale for up to 5 minutes while revalidating.
"Cache-Control": "public, max-age=3600, stale-while-revalidate=300",
},
});
},
} satisfies ExportedHandler;Deploy your Worker:
npx wrangler deploy
Then send two requests and look at the Cf-Cache-Status response header:
curl -I https://my-worker.example.workers.dev/
First request expectedtxtHTTP/2 200
cache-control: public, max-age=3600, stale-while-revalidate=300
cf-cache-status: MISS
curl -I https://my-worker.example.workers.dev/
Second request expectedtxtHTTP/2 200
cache-control: public, max-age=3600, stale-while-revalidate=300
cf-cache-status: HIT
The second request receives the cached response. The timestamp and random values in the body are identical between the two requests, even though the Worker generates fresh ones on every run confirming that the second request did not execute your Worker.
fetch handler are eligible for caching, including eyeball requests, service binding fetch() calls, and loopback fetch() calls via ctx.exports.GET and HEAD requests are cached. Other methods always invoke your Worker. GET and HEAD for the same URL share a single cache entry see Cache keys.fetch() invocations go through the cache. Custom RPC methods on a WorkerEntrypoint (for example ctx.exports.Backend.getUser(id)) bypass the cache entirely and always run the callee. To cache a piece of work, expose it as a fetch handler on its own entrypoint.GET request carrying Upgrade: websocket always invokes your Worker.scheduled (Cron Triggers), queue consumers, Workflows, Tail Workers, Durable Object invocations, Email Workers always run without cache involvement.Cache-Control. Refer to Cache-Control for the full list of directives Cloudflare respects.Set-Cookie header and requests with an Authorization header trigger automatic bypass.The Cf-Cache-Status response header tells you what happened for each request.
The values you will see most often are HIT, MISS, EXPIRED, REVALIDATED,
UPDATING, STALE, and BYPASS. Refer to Cloudflare cache
responses for the full set of values.
Workers Caching honors the Vary response header as defined in RFC 9110 and RFC 9111 . When your Worker returns a Vary header, Cloudflare stores a separate cached variant per distinct combination of the listed request header values, and only returns a cached variant when the incoming request's headers match the ones the variant was stored under.
This lets a single URL cache multiple representations for example, different encodings, different content types, or different languages without your Worker coordinating content negotiation by hand:
export default {
async fetch(request) {
const accept = request.headers.get("Accept") ?? "";
const wantsWebp = accept.includes("image/webp");
const body = wantsWebp ? await fetchWebpImage() : await fetchJpegImage();
return new Response(body, {
headers: {
"Content-Type": wantsWebp ? "image/webp" : "image/jpeg",
"Cache-Control": "public, max-age=3600",
// Cache a separate variant per distinct Accept header value.
Vary: "Accept",
},
});
},
};export default {
async fetch(request): Promise<Response> {
const accept = request.headers.get("Accept") ?? "";
const wantsWebp = accept.includes("image/webp");
const body = wantsWebp ? await fetchWebpImage() : await fetchJpegImage();
return new Response(body, {
headers: {
"Content-Type": wantsWebp ? "image/webp" : "image/jpeg",
"Cache-Control": "public, max-age=3600",
// Cache a separate variant per distinct Accept header value.
Vary: "Accept",
},
});
},
} satisfies ExportedHandler;Notes:
Vary: * disables caching for the response. A wildcard variance cannot be satisfied deterministically from request headers, so Cloudflare does not store the response.Cache-Tag values.Vary is not compatible with image-transformation features that already produce their own variants (Polish, Image Resizing). Responses rewritten by those features ignore Vary.Accept-Encoding: gzip, br and Accept-Encoding: br, gzip produce separate variants. Shape the headers your Worker sees (for example, by normalizing them in a gateway Worker before passing the request on) if you need to reduce variant fan-out.When one Worker calls another over a service binding, the callee's cache is consulted. If the callee has caching enabled and has a matching cached response, the caller receives it without invoking the callee.
flowchart LR
accTitle: Cache between Workers
accDescr: Worker A calls Worker B; Worker B's cache is consulted before Worker B runs.
Request["Request"] --> WorkerA["Worker A"]
WorkerA --> CacheB{"Worker B's cache"}
CacheB -- Hit --> WorkerA
CacheB -- Miss --> WorkerB["Worker B"]
WorkerB --> CacheB
The cache key for service binding calls includes the caller's ctx.props, so different callers with different authorization context are cached separately. For details, refer to Cache keys.
For same-account calls, the calling Worker can also tailor the callee's caching for an individual request by setting cf.cacheKey to override the cache key or cf.cacheControl to supply a Cache-Control directive.
Durable Objects are never cached directly by Workers Caching. However, since Workers Caching runs in front of any Worker entrypoint, you can cache a Durable Object's HTTP responses by wrapping the Durable Object behind a named Worker entrypoint and caching the entrypoint.
The wrapper entrypoint forwards the request into the Durable Object and sets Cache-Control on the response it returns. Because Workers Caching sits in front of the entrypoint, subsequent requests are served from cache without re-entering the Durable Object.
The default entrypoint here is a gateway that should run on every request, so disable caching on it and enable it on CachedCounter (see Per-entrypoint caching):
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-19",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedCounter": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-19"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.CachedCounter]
type = "worker"
[exports.CachedCounter.cache]
enabled = trueimport { WorkerEntrypoint } from "cloudflare:workers";
// Cached entrypoint. Requests to this entrypoint are served from cache
// when possible; on a miss, the Durable Object is invoked and its
// response is stored.
export class CachedCounter extends WorkerEntrypoint {
async fetch(request) {
const id = this.env.COUNTER.idFromName("global");
const stub = this.env.COUNTER.get(id);
const response = await stub.fetch(request);
// Attach cache headers. Clone into a new Response so the headers
// are mutable.
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
"Cache-Control": "public, max-age=30",
},
});
}
}
// Default entrypoint. Delegates to the cached entrypoint via ctx.exports,
// which routes through the cache.
export default {
async fetch(request, env, ctx) {
return ctx.exports.CachedCounter.fetch(request);
},
};import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
COUNTER: DurableObjectNamespace;
}
// Cached entrypoint. Requests to this entrypoint are served from cache
// when possible; on a miss, the Durable Object is invoked and its
// response is stored.
export class CachedCounter extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const id = this.env.COUNTER.idFromName("global");
const stub = this.env.COUNTER.get(id);
const response = await stub.fetch(request);
// Attach cache headers. Clone into a new Response so the headers
// are mutable.
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
"Cache-Control": "public, max-age=30",
},
});
}
}
// Default entrypoint. Delegates to the cached entrypoint via ctx.exports,
// which routes through the cache.
export default {
async fetch(request, env, ctx): Promise<Response> {
return ctx.exports.CachedCounter.fetch(request);
},
} satisfies ExportedHandler<Env>;For more patterns that combine a gateway entrypoint with cached inner entrypoints, refer to Examples.
Smart Placement moves where your Worker runs when it runs typically closer to a slow origin or database. It does not move the cache. Workers Caching always has a lower tier near the eyeball and an upper tier aggregating the network, exactly as described in Tiered cache above, whether or not Smart Placement is enabled.
The cache is always consulted before Smart Placement is considered. Concretely:
Importantly, the upper tier and the Smart Placement target are independent locations. The upper tier is chosen by Cloudflare to aggregate cache fills across the network; the Smart Placement target is chosen to minimize latency between your Worker and its backend. They are generally not in the same data center.
flowchart LR
accTitle: Tiered cache with Smart Placement across three locations
accDescr: The eyeball, the upper-tier cache, and the Smart Placement target are three independent locations. Requests traverse them in order on a full cache miss.
subgraph EyeballColo["Data center near eyeball"]
Request["Request"] --> Lower{"Lower-tier cache"}
end
subgraph UpperColo["Upper-tier data center"]
Upper{"Upper-tier cache"}
end
subgraph PlacedColo["Smart Placement target"]
Placed["Worker runs"]
Origin["Origin / backend"]
Placed <--> Origin
end
Lower -- Hit --> Response["Response"]
Lower -- Miss --> Upper
Upper -- Hit --> Lower
Upper -- Miss --> Placed
Placed --> Upper
On a full cache miss, a request therefore traverses three locations: the lower-tier data center near the eyeball, the upper-tier data center, and the Smart Placement target. The cache tiers absorb this cost so that the slow trip to the placement target is only paid once for the whole network the upper tier shields the placement target from every lower-tier miss.
Future optimization
Because the upper tier and the Smart Placement target are chosen independently today, a cache miss pays for an extra hop between them. Future updates may bring these choices closer together for example, by co-locating the upper tier with the placement target, or by having a single placement decision account for both so that full cache misses incur one wide-area trip instead of two.
Your Worker can invalidate its own cache at any time using ctx.cache.purge(). Tags are the most flexible mechanism tag responses with Cache-Tag when returning them, and purge those tags later:
export default {
async fetch(request, env, ctx) {
await ctx.cache.purge({ tags: ["blog-posts"] });
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
await ctx.cache.purge({ tags: ["blog-posts"] });
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;You can also import cache from cloudflare:workers and call cache.purge({...}) when you do not have ctx in scope for example, from a utility module. For all purge modes and patterns, refer to Purging the cache.
Workers Cache has no separate pricing. When you enable Workers Cache, all requests to your Worker are billed at the standard Workers request rate the same per-request rate as any other request to your Worker whether the response comes from cache or from your Worker. There is no charge beyond the standard request rate. CPU time is only billed when your Worker runs cache hits do not consume CPU time.
Caching bills requests that are normally free
When caching is enabled, every request to your Worker is charged at the standard Workers request rate, including requests that are normally free: static asset requests and worker-to-worker invocations through service bindings or ctx.exports.
| Request type | Request charge | CPU time charge |
|---|---|---|
Cache HIT (Worker does not 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 invocation | Standard rate | Billed if Worker runs |
For an example, refer to Pricing example: Worker with caching.
| Web Proxy Viewer | New URL | Original Page |