INTEGRITY Cloudflare Docs

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:

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

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:

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:

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:

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",
			},
		});
	}
}
src/backend.ts
import { WorkerEntrypoint } from "cloudflare:workers";

interface Props {
	userId: string;
}

export default class Backend extends WorkerEntrypoint<Env, Props> {
	async fetch(request: Request): Promise<Response> {
		// 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");
	},
};
src/gateway.ts
interface Env {
	BACKEND: Fetcher;
}

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// "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");
	},
} satisfies ExportedHandler<Env>;

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 },
		});
	},
};
src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

// Cached entrypoint. Requests routed here through ctx.exports are served
// from cache when possible.
export class Backend extends WorkerEntrypoint<Env> {
	async fetch(request: Request): Promise<Response> {
		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): Promise<Response> {
		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 },
		});
	},
} satisfies ExportedHandler<Env>;

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:

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):

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

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.Backend]
type = "worker"

  [exports.Backend.cache]
  enabled = true

What you can do with a custom cache key

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.