---
title: Cache keys
description: How Workers Caching builds cache keys, with guidance for service bindings, multi-tenant Workers, and gradual deployments.
image: https://developers.cloudflare.com/dev-products-preview.png
---

> Documentation Index  
> Fetch the complete documentation index at: https://developers.cloudflare.com/workers/llms.txt  
> Use this file to discover all available pages before exploring further. 

[Skip to content](#%5Ftop) 

# 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](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#named-entrypoints) 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](https://developers.cloudflare.com/workers/cache/configuration/#cross-version-caching) to share cached responses across versions. See [Invalidating cache across deployments](#invalidating-cache-across-deployments).
* The invocation's [ctx.props](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#ctxprops), when the Worker is invoked through a service binding or RPC. See [Multi-tenant safety with ctx.props](#multi-tenant-safety-with-ctxprops).

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](https://developers.cloudflare.com/workers/cache/configuration/#cross-version-caching).

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 ↗](https://portswigger.net/research/practical-web-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](https://developers.cloudflare.com/workers/cache/configuration/#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-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](https://developers.cloudflare.com/workers/observability/). See [Inspecting the cache key](#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](https://developers.cloudflare.com/workers/configuration/routing/routes/) on any zone you control.
* Through a [custom domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) — and you can bind the same Worker to many custom domains.
* Through a [service binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) 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](https://developers.cloudflare.com/workers/versions-and-deployments/gradual-deployments/), 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](https://developers.cloudflare.com/workers/cache/configuration/#cross-version-caching) 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](https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/) to read the current version ID at request time, and prepends it as a `Cache-Tag` value. See [Version-specific purging](https://developers.cloudflare.com/workers/cache/purge/#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 })](https://developers.cloudflare.com/workers/cache/purge/#purge-everything). 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](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) or [RPC](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/), the caller's [ctx.props](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#ctxprops) 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.

* [  JavaScript ](#tab-panel-11843)
* [  TypeScript ](#tab-panel-11844)

**src/backend.js**

```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**

```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",
      },
    });
  }
}
```

Warning

If you authenticate callers through some mechanism other than `ctx.props` — for example, by reading a custom header your gateway Worker attaches — that input is **not** automatically part of the cache key. Two callers authenticated by different header values but otherwise identical requests will share a single cached entry, which means one caller can receive another caller's response.

The fix is to move per-caller authorization state into `ctx.props`. Your gateway Worker should populate `ctx.props` with whatever distinguishes callers before invoking the cached Worker. Cloudflare's standard [automatic bypass rules](https://developers.cloudflare.com/cache/concepts/cache-responses/#bypass) (`Set-Cookie`, `Authorization`) can also prevent the problem by disabling caching entirely for authenticated requests, but `ctx.props` is the recommended approach because it still lets you cache.

### 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()](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/#use-the-fetch-method), 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-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`:

* [  JavaScript ](#tab-panel-11841)
* [  TypeScript ](#tab-panel-11842)

**src/gateway.js**

```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**

```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](https://developers.cloudflare.com/cache/concepts/cache-responses/) for the full set of values.
2. **Cache hits in the [Workers observability dashboard](https://developers.cloudflare.com/workers/observability/).** 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](#what-goes-into-the-cache-key). For a walkthrough of common caching problems and how to diagnose them, refer to [Debugging](https://developers.cloudflare.com/workers/cache/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](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/) 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:

* [  JavaScript ](#tab-panel-11845)
* [  TypeScript ](#tab-panel-11846)

**src/index.js**

```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**

```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](#what-goes-into-the-cache-key) still applies:

* The target entrypoint and the caller's [ctx.props](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/#ctxprops) remain part of the key. A custom cache key cannot reach across entrypoints or across `ctx.props`, so the [multi-tenant isolation](#multi-tenant-safety-with-ctxprops) 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](https://developers.cloudflare.com/workers/cache/configuration/#per-entrypoint-caching)):

* [  wrangler.jsonc ](#tab-panel-11839)
* [  wrangler.toml ](#tab-panel-11840)

**JSONC**

```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 } },
  },
}
```

**TOML**

```toml
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"


  [exports.default.cache]
  enabled = false


[exports.Backend]
type = "worker"


  [exports.Backend.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](#multi-tenant-safety-with-ctxprops) 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.

```json
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/workers/cache/cache-keys/#page","headline":"Cache keys · Cloudflare Workers docs","description":"How Workers Caching builds cache keys, with guidance for service bindings, multi-tenant Workers, and gradual deployments.","url":"https://developers.cloudflare.com/workers/cache/cache-keys/","inLanguage":"en","image":"https://developers.cloudflare.com/dev-products-preview.png","dateModified":"2026-07-06","publisher":{"@type":"Organization","name":"Cloudflare","url":"https://www.cloudflare.com/"},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"/directory/","name":"Directory"}},{"@type":"ListItem","position":2,"item":{"@id":"/workers/","name":"Workers"}},{"@type":"ListItem","position":3,"item":{"@id":"/workers/cache/","name":"Workers Cache"}},{"@type":"ListItem","position":4,"item":{"@id":"/workers/cache/cache-keys/","name":"Cache keys"}}]}
```
