Skip to content

Cache keys

Every cached response is stored under a cache key. When a request arrives, Cloudflare computes a cache key for it and looks it up — on a hit, the stored response is returned; on a miss, your Worker runs and its response is stored under that key for next time.

Two requests that produce the same cache key share the same cached response. Two requests that produce different cache keys get independent cached entries.

This page explains what Workers Caching puts into the cache key, why each component is there, and how to reason about it when designing your Worker.

What goes into the cache key

Workers Caching keys responses by:

  • The target entrypoint — which specific named entrypoint of the Worker received the request. A default export and an exported class are different entrypoints and do not share a cache even if they produce identical responses.
  • The path and query string of the request URL. Query parameter order matters — ?a=1&b=2 and ?b=2&a=1 are different cache keys. Trailing slashes matter too.
  • The Worker version, by default. Each deployed version has its own cache, so a new deployment does not serve responses written by a previous version. You can turn this off with cache.cross_version_cache to share cached responses across versions. See Invalidating cache across deployments.
  • The invocation's ctx.props, when the Worker is invoked through a service binding or RPC. See Multi-tenant safety with ctx.props.

As anti-cache-poisoning measures, the key also includes:

  • The x-http-method-override, x-http-method, and x-method-override request headers.
  • The x-forwarded-host, x-host, x-forwarded-scheme (unless its value is http or https), x-original-url, x-rewrite-url, and forwarded request headers.
  • The value of the Cloudflare-Workers-Version-Key request header. This header is not set by Cloudflare automatically — it is only meaningful if a caller (for example, an upstream Worker or proxy) chooses to include it to explicitly partition the cache further. This is independent of the automatic per-version keying described above, which is controlled by cache.cross_version_cache.

These three bullets are not something you should normally need to reason about. Some frameworks interpret the method-override and URL-rewrite headers as overriding the effective method or URL of a request, which can lead to cache poisoning if two requests differ only in those headers but produce materially different responses. Including them in the cache key ensures a poisoned entry only affects requests that carry the same poisoned header.

Requests that differ only in request headers that are not part of the cache key (for example, User-Agent, Accept-Language, Cookie, or Authorization) return the same cached response. This is usually what you want — you do not want every user agent string or language preference producing a separate cache entry. If you do need content negotiation, set Vary on the response, or handle it inside your Worker and produce a canonical response per URL.

Notably, the cache key does not include:

  • The HTTP method. GET and HEAD requests for the same URL share a single cache entry. A HEAD request can be served from a GET fill (Cloudflare returns the cached headers without the body). In the other direction, a HEAD request on a cold cache is converted to a GET internally so the full asset is fetched and stored — a subsequent GET then hits the entry that HEAD populated. (POST, PUT, PATCH, and DELETE are never cached at all, so the question does not arise for them.)
  • The request's host. The Worker's cache is keyed by path and query string, not the full URL. See The cache belongs to the Worker, not to a domain.
  • The request body. Since only GET and HEAD are cacheable, this is rarely relevant — but worth noting if your Worker reads request.body on a cacheable method, the body does not partition the cache.

At launch, you cannot inspect the exact cache key Cloudflare computed for a request. The primary signals you have for understanding cache behavior are the Cf-Cache-Status response header and per-invocation cache-hit information in the Workers observability dashboard. See Inspecting the cache key.

The cache belongs to the Worker, not to a domain

A Worker is a zoneless entity. It can be invoked through several different paths:

  • Directly on a workers.dev subdomain.
  • Through a route on any zone you control.
  • Through a custom domain — and you can bind the same Worker to many custom domains.
  • Through a service binding from another Worker, with an arbitrary placeholder hostname in the URL.

Workers Caching treats all of these as the same Worker and uses a single shared cache across them. The cache key does not include the host, so a request to /api/users/42 hits the same cached entry whether it came in through api.example.com, api.example.net, a service binding, or a workers.dev URL.

This is the behavior you almost always want. A Worker's responses are a function of its code and its inputs, not of which domain the request arrived through — so caching them once and serving that response back to every ingress path maximizes the cache hit rate without losing correctness.

If you genuinely need different cached responses for the same path on different hostnames — for example, white-labeled tenants where tenant-a.example.com/index and tenant-b.example.com/index must produce different content — the cache key does not do this for you automatically. Instead, distinguish the tenants at your gateway Worker and pass the tenant identifier via ctx.props, which is part of the cache key.

Invalidating cache across deployments

By default, the currently invoked Worker version is part of the cache key. Each deployed version has its own cache, so:

  • A new deployment starts from a cold cache and never serves responses that a previous version wrote.
  • Cache-affecting changes apply immediately when the new version goes live — you do not have to purge anything to stop serving old content.
  • During a gradual deployment, the old and new versions populate independent caches, so a slice of traffic on the new version never receives the old version's responses.

This is the default because it is the simplest behavior to reason about. The trade-off is that cache hit rate resets on every deployment — the first requests to a new version are misses while its cache fills. This is the most common reason a Worker's cache hit rate drops right after a deploy.

Share the cache across versions

If you deploy frequently and your responses rarely change between deployments, throwing away a warm cache on every deploy is wasteful. Set cache.cross_version_cache to true to drop the version from the cache key and share cached responses across versions. A response written by version A is then still served after version B is deployed, as long as its TTL has not expired.

This maximizes cache hit rate at the expense of slower rollouts: because a deployment no longer invalidates the cache, a change that alters response content will not take effect for already-cached entries until they expire or you purge them. When you have cross_version_cache enabled and need a deployment to take effect immediately, use one of the two tools below.

Tag responses by version, purge the tag on rollback

If you want fine-grained control, tag each cached response with the Worker version that produced it. Later, purging that version tag removes every entry that version wrote, without affecting cached responses from other versions.

This uses the version metadata binding to read the current version ID at request time, and prepends it as a Cache-Tag value. See Version-specific purging for the full pattern with code.

This is the best option if you have enabled cross_version_cache and might need to roll back a specific version without blowing away cached content from working versions.

Purge everything after deploy

The simpler approach: after each deploy, hit a small Worker endpoint from your CI that calls ctx.cache.purge({ purgeEverything: true }). The next request after the purge re-populates the cache from whichever Worker version is live at that moment.

This is coarser but requires zero in-Worker logic. Use it if you have enabled cross_version_cache but still want specific deployments to invalidate the cache. With the default per-version cache, deployments already start from a cold cache, so this is unnecessary.

Multi-tenant safety with ctx.props

When your Worker is invoked through a service binding or RPC, the caller's ctx.props is part of the cache key. Two callers that invoke your Worker with different ctx.props get separate cached entries — one caller can never receive another caller's cached response.

This is the mechanism that makes caching safe for multi-tenant Workers invoked over a service binding. If you use ctx.props to carry per-caller authorization context — user ID, tenant ID, organization, role — caching is safe by default. Responses that logically belong to one caller cannot leak to another through the cache.

src/backend.js
import { WorkerEntrypoint } from "cloudflare:workers";
export default class Backend extends WorkerEntrypoint {
async fetch(request) {
// ctx.props.userId is set by the caller (for example, an auth gateway).
// Because it is part of the cache key, User A and User B requesting the
// same URL get separate cache entries — there is no way for one to
// see the other's response.
const { userId } = this.ctx.props;
const data = { userId, timestamp: Date.now() };
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
});
}
}

Service binding URL

Service binding calls deserve a specific note because the URL you pass does not mean what you might think it means.

When you call a service binding with fetch(), the hostname in the URL is a placeholder. The request is routed via the binding, not by DNS — the hostname is never resolved. And because the host is not part of the cache key (as described in The cache belongs to the Worker, not to a domain), the placeholder has no effect on caching either. Only the path (and query string) contribute to the cache key, alongside the target entrypoint and ctx.props:

src/gateway.js
export default {
async fetch(request, env, ctx) {
// "internal" here is just a placeholder — it is not routed anywhere
// and is not part of the cache key.
//
// What identifies this cached response is:
// - the BACKEND entrypoint
// - the path "/api/users/42"
// - whatever ctx.props the gateway passes along
return env.BACKEND.fetch("http://internal/api/users/42");
},
};

If you want cached responses to differ for different callers, vary ctx.props. If you want them to differ by request, vary the path or query string. Varying the hostname does nothing.

Inspecting the cache key

At launch, two signals give you visibility into cache behavior:

  1. The Cf-Cache-Status response header. The values you will see most often are HIT, MISS, EXPIRED, REVALIDATED, UPDATING, STALE, and BYPASS. HIT means Cloudflare returned a cached response without running your Worker. MISS means your Worker ran and the response was stored. UPDATING means the cached response was stale and your Worker ran in the background to refresh it. BYPASS means caching was disabled for this request. Refer to Cloudflare cache responses for the full set of values.

  2. Cache hits in the Workers observability dashboard. Each invocation surfaces whether it was served from cache, so you can filter and aggregate cache-hit behavior across your Worker's traffic.

Cloudflare does not currently expose the cache key composition itself. If two requests you expected to share a cached response do not, you have to reason about what part of the key differed from the components listed in What goes into the cache key. For a walkthrough of common caching problems and how to diagnose them, refer to Debugging.

Custom cache keys

By default, the path and query string of the request URL form the URL component of the cache key. When one entrypoint invokes another cached entrypoint through a ctx.exports loopback, the calling entrypoint can override that component by setting cf.cacheKey on the request.

In the example below, the Backend entrypoint is the cached one. The default entrypoint forwards requests to it through ctx.exports, choosing the cache key itself:

src/index.js
import { WorkerEntrypoint } from "cloudflare:workers";
// Cached entrypoint. Requests routed here through ctx.exports are served
// from cache when possible.
export class Backend extends WorkerEntrypoint {
async fetch(request) {
return new Response("Hello from the backend", {
headers: {
"Content-Type": "text/html",
"Cache-Control": "public, max-age=3600",
},
});
}
}
// Gateway entrypoint. Calls the cached Backend entrypoint via ctx.exports,
// which routes through the cache, and chooses the cache key for the call.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Strip a tracking parameter so that requests differing only by
// `utm_source` resolve to the same cached entry.
url.searchParams.delete("utm_source");
return ctx.exports.Backend.fetch(request, {
cf: { cacheKey: url.pathname + url.search },
});
},
};

A custom cache key replaces the path and query string in the cache key. Everything else described in What goes into the cache key still applies:

  • The target entrypoint and the caller's ctx.props remain part of the key. A custom cache key cannot reach across entrypoints or across ctx.props, so the multi-tenant isolation described above still holds even when callers choose their own keys. A custom key only ever addresses entries within the callee's own cache namespace.
  • Two requests with different URLs but the same cf.cacheKey resolve to the same cache entry. This is how you collapse several URLs onto a single cached response.
  • Two requests with the same URL but different cf.cacheKey resolve to separate cache entries.

Set cf.cacheKey to an empty string, or omit it, to fall back to the default URL-derived key.

In this pattern the default entrypoint is a gateway that should run on every request, so disable caching on it and leave it on for Backend (see Per-entrypoint caching):

JSONC
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-07-06",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"Backend": { "type": "worker", "cache": { "enabled": true } },
},
}

What you can do with a custom cache key

  • Ignore parts of the URL. Strip tracking parameters (utm_source, gclid), or drop a query string entirely, so that variations that do not change the response share one cache entry.
  • Key on something other than the URL. Build the key from a value your gateway Worker trusts — for example, a normalized resource identifier — so that several equivalent URLs map to one entry.
  • Partition the cache yourself. Append a discriminating value to the key (for example, a content version) to force separate entries for requests that would otherwise collide.

For per-caller isolation, continue to use ctx.props rather than encoding caller identity into the cache key — ctx.props is part of the key automatically and cannot be bypassed.

Custom keys apply to same-account calls only

cf.cacheKey is honored only when the call stays within your account. Cloudflare drops the cf object whenever a request crosses an account boundary — for example, a service binding to a Worker owned by a different account. When that happens, the custom key is disregarded and the cache key falls back to the request URL, so a caller in one account can never influence (or probe) the cache of a Worker in another account.

This also means cf.cacheKey has no effect on eyeball requests. The cf object on an inbound request from a browser or API client is populated by Cloudflare, not by the client, so a client cannot set its own cache key.