Skip to content

Examples

Last updated View as MarkdownAgent setup

Workers Caching is a cache that is itself a Worker primitive. It sits in front of every Worker entrypoint — the default export and every named WorkerEntrypoint — and it also sits in front of fetch() calls between entrypoints in the same Worker via ctx.exports. That second fact is the one that makes the rest of this page possible.

When one entrypoint invokes another's fetch() via ctx.exports, the cache evaluates that call the same way it would evaluate a request from a browser. A hit returns the cached response without the callee running. A miss runs the callee and stores the response under its own cache key, keyed by the callee's entrypoint, path, query string, and ctx.props. The caller still runs on every request — but anything the caller hands off to the callee is cacheable independently.

That gives you a primitive you can compose. You can author a Worker as a chain of small entrypoints — auth, normalization, routing, the expensive read, the data layer — and let Workers Caching slot in wherever you want it. Each cached entrypoint is a unit of memoization with its own key, its own TTL, and its own tag namespace for purging. Anything you would want to configure about caching — when it runs, what it keys on, when it invalidates — is expressed as ordinary Worker code: which entrypoint you call, what request you forward, what ctx.props you pass, what Cache-Control you set.

The examples on this page all use the same shape: an outer (gateway) entrypoint that runs every request, plus one or more inner entrypoints that are cached. The outer entrypoint does something cheap (authenticate, rewrite a header, pick a route); the inner entrypoint does something expensive (look up data, transform it, run a Durable Object). They are written as classes in one source file, deployed as one Worker, billed as one Worker — connected by a cache stage that sits in front of the inner entrypoint.

Two rules to keep in mind

Two facts shape every pattern below. They follow directly from "the cache is in front of every entrypoint":

Disable caching on the gateway entrypoint. Because the cache sits in front of every entrypoint by default, the outer entrypoint would itself be cached — and the next request would be served from that outer cache without ever entering your gateway logic. Turn caching off for the gateway entrypoint in your Wrangler configuration, and leave it on for the inner entrypoint the gateway forwards to. Using "default" for the default export:

{
	"name": "my-worker",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-14",
	"cache": { "enabled": true },
	"exports": {
		// The gateway runs on every request — no caching in front of it.
		"default": { "type": "worker", "cache": { "enabled": false } },
		// The inner entrypoint is the one that gets cached.
		"Inner": { "type": "worker", "cache": { "enabled": true } },
	},
}
name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-14"

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.Inner]
type = "worker"

  [exports.Inner.cache]
  enabled = true

Strip request headers that would force a bypass. Cloudflare's standard bypass rules apply to the inner entrypoint's cache too — an Authorization header on the forwarded request will turn every inner call into a BYPASS, and nothing will ever be stored. When the outer entrypoint authenticates the request and decides it is safe to cache, it must strip Authorization (and anything else that triggers automatic bypass) before invoking the inner entrypoint.

Both rules apply to every example below.

Cache authenticated responses

Caching authenticated APIs has historically been awkward. The standard bypass rules treat any request with an Authorization header as private and refuse to cache it — which is the safe default, but means a token-authenticated endpoint that returns identical responses to thousands of users runs your Worker every single time.

The pattern below lets you authenticate every request and still serve cache hits without running the cacheable handler:

  1. The outer (default) entrypoint receives the request and authenticates it.
  2. On success, it strips the Authorization header and forwards the request to a named entrypoint via ctx.exports.
  3. Workers Caching sits in front of the named entrypoint. On a hit, the cached response is returned to the outer entrypoint, which returns it to the client — without the named entrypoint ever running.

Disable caching on the default entrypoint so it runs on every request to authenticate, and keep it on for CachedAPI:

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

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.CachedAPI]
type = "worker"

  [exports.CachedAPI.cache]
  enabled = true
src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";

// Cached entrypoint. Workers Caching sits in front of this — on a hit,
// the cached response is returned and `fetch` below is never invoked.
export class CachedAPI extends WorkerEntrypoint {
	async fetch(request) {
		const data = await loadExpensiveData(request);

		return new Response(JSON.stringify(data), {
			headers: {
				"Content-Type": "application/json",
				// All authenticated callers see this same response on a hit.
				"Cache-Control": "public, max-age=60",
			},
		});
	}
}

// Default entrypoint. Runs on every request to authenticate the caller,
// then forwards to the cached entrypoint.
export default {
	async fetch(request, env, ctx) {
		if (!(await authenticate(request, env))) {
			return new Response("Unauthorized", { status: 401 });
		}

		// Strip the Authorization header before forwarding. Otherwise the
		// request would trigger Cloudflare's automatic bypass for
		// authenticated requests, and nothing would ever be cached.
		const forwarded = new Request(request);
		forwarded.headers.delete("Authorization");

		// Caching is disabled for this gateway entrypoint (see the Wrangler
		// configuration above), so it runs on every request. Forward to the
		// cached CachedAPI entrypoint and return its response directly.
		return ctx.exports.CachedAPI.fetch(forwarded);
	},
};

async function authenticate(request, env) {
	const token = request.headers.get("Authorization")?.replace(/^Bearer\s+/, "");
	return token === env.API_TOKEN;
}

async function loadExpensiveData(request) {
	// Replace with your real data source — D1, KV, an origin, and so on.
	return { timestamp: Date.now() };
}
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
	API_TOKEN: string;
}

// Cached entrypoint. Workers Caching sits in front of this — on a hit,
// the cached response is returned and `fetch` below is never invoked.
export class CachedAPI extends WorkerEntrypoint<Env> {
	async fetch(request: Request): Promise<Response> {
		const data = await loadExpensiveData(request);

		return new Response(JSON.stringify(data), {
			headers: {
				"Content-Type": "application/json",
				// All authenticated callers see this same response on a hit.
				"Cache-Control": "public, max-age=60",
			},
		});
	}
}

// Default entrypoint. Runs on every request to authenticate the caller,
// then forwards to the cached entrypoint.
export default {
	async fetch(request, env, ctx): Promise<Response> {
		if (!(await authenticate(request, env))) {
			return new Response("Unauthorized", { status: 401 });
		}

		// Strip the Authorization header before forwarding. Otherwise the
		// request would trigger Cloudflare's automatic bypass for
		// authenticated requests, and nothing would ever be cached.
		const forwarded = new Request(request);
		forwarded.headers.delete("Authorization");

		// Caching is disabled for this gateway entrypoint (see the Wrangler
		// configuration above), so it runs on every request. Forward to the
		// cached CachedAPI entrypoint and return its response directly.
		return ctx.exports.CachedAPI.fetch(forwarded);
	},
} satisfies ExportedHandler<Env>;

async function authenticate(request: Request, env: Env): Promise<boolean> {
	const token = request.headers.get("Authorization")?.replace(/^Bearer\s+/, "");
	return token === env.API_TOKEN;
}

async function loadExpensiveData(request: Request): Promise<unknown> {
	// Replace with your real data source — D1, KV, an origin, and so on.
	return { timestamp: Date.now() };
}

A few things to notice:

  • The cache is in the right place. It sits between the outer entrypoint and the cached entrypoint, so cache hits skip the expensive work entirely. Only the auth check runs.
  • Authorization is stripped before forwarding. This is what makes the response cacheable — Cloudflare's bypass rule fires on the inbound request, not on the response, so removing the header before the request reaches the cached entrypoint is what lets the cached entrypoint's Cache-Control: public take effect. It also prevents tokens from contributing to any future cache key.
  • The cached response is shared across users. Every caller who passes the auth check sees the same cached body.

Per-user authenticated responses

If your endpoint returns user-specific data, pass the user identifier via ctx.props. Workers Caching includes ctx.props in the cache key, so each user gets their own cache entry and one user can never receive another user's cached response. This uses the same Wrangler configuration as the previous example — caching disabled on default, enabled on CachedAPI:

src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";

export class CachedAPI extends WorkerEntrypoint {
	async fetch(request) {
		// ctx.props.userId is part of the cache key, so this response
		// is cached separately for every userId.
		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=60",
			},
		});
	}
}

export default {
	async fetch(request, env, ctx) {
		const userId = await authenticate(request, env);
		if (!userId) {
			return new Response("Unauthorized", { status: 401 });
		}

		const forwarded = new Request(request);
		forwarded.headers.delete("Authorization");

		// The gateway's cache is disabled, so it runs on every request.
		// Pass the authenticated userId to the cached entrypoint via props —
		// this becomes part of the cache key.
		return ctx.exports.CachedAPI.fetch(forwarded, {
			props: { userId },
		});
	},
};

async function authenticate(request, env) {
	// Replace with your real auth — JWT verification, token lookup, and so on.
	return "user-42";
}

async function loadUserData(userId) {
	return { userId, timestamp: Date.now() };
}
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
	API_TOKEN: string;
}

interface Props {
	userId: string;
}

export class CachedAPI extends WorkerEntrypoint<Env, Props> {
	async fetch(request: Request): Promise<Response> {
		// ctx.props.userId is part of the cache key, so this response
		// is cached separately for every userId.
		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=60",
			},
		});
	}
}

export default {
	async fetch(request, env, ctx): Promise<Response> {
		const userId = await authenticate(request, env);
		if (!userId) {
			return new Response("Unauthorized", { status: 401 });
		}

		const forwarded = new Request(request);
		forwarded.headers.delete("Authorization");

		// The gateway's cache is disabled, so it runs on every request.
		// Pass the authenticated userId to the cached entrypoint via props —
		// this becomes part of the cache key.
		return ctx.exports.CachedAPI.fetch(forwarded, {
			props: { userId },
		});
	},
} satisfies ExportedHandler<Env>;

async function authenticate(
	request: Request,
	env: Env,
): Promise<string | null> {
	// Replace with your real auth — JWT verification, token lookup, and so on.
	return "user-42";
}

async function loadUserData(userId: string): Promise<unknown> {
	return { userId, timestamp: Date.now() };
}

For more on cache isolation between callers, refer to Multi-tenant safety with ctx.props.

The shape of this example — the outer entrypoint shapes a value (the user's identity) into the cache key by passing it through ctx.props — is the same shape the next example uses to influence a different part of the key.

Normalize Accept-Encoding for Vary

Vary lets a single URL cache multiple representations — for example, a Brotli-encoded and gzip-encoded variant of the same asset. Cloudflare keys variants on the verbatim value of each Vary-listed request header, so two requests with semantically equivalent but textually different Accept-Encoding headers produce two separate variants.

For requests routed through Cloudflare's front line, this matters even more: the Accept-Encoding request header your Worker sees has typically been rewritten by Cloudflare to a canonical value (such as gzip, br) for cache efficiency. The original value is preserved at request.cf.clientAcceptEncoding, but if your Worker varies on Accept-Encoding without restoring the eyeball's value first, every cached variant ends up keyed on the rewritten string — so the cache returns a Brotli variant to clients that only accept gzip, or the other way around.

The fix is a gateway entrypoint that restores Accept-Encoding from request.cf.clientAcceptEncoding before forwarding to the cached entrypoint. Disable caching on the gateway and enable it on CachedAssets:

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

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.CachedAssets]
type = "worker"

  [exports.CachedAssets.cache]
  enabled = true
src/index.jsjs
import { WorkerEntrypoint } from "cloudflare:workers";

export class CachedAssets extends WorkerEntrypoint {
	async fetch(request) {
		const accept = request.headers.get("Accept-Encoding") ?? "";
		const wantsBrotli = accept.includes("br");

		const { body, encoding } = wantsBrotli
			? await loadBrotli(request)
			: await loadGzip(request);

		return new Response(body, {
			headers: {
				"Content-Type": "application/javascript",
				"Content-Encoding": encoding,
				"Cache-Control": "public, max-age=86400, immutable",
				// One variant per distinct Accept-Encoding value the cached
				// entrypoint sees. The gateway below normalizes that value.
				Vary: "Accept-Encoding",
			},
		});
	}
}

export default {
	async fetch(request, env, ctx) {
		// On Cloudflare, the eyeball's Accept-Encoding is usually rewritten
		// to a canonical value before the Worker runs. Restore it from
		// request.cf.clientAcceptEncoding so the cached entrypoint sees
		// what the client actually sent — and so Vary keys variants on
		// the real value.
		const original = request.cf?.clientAcceptEncoding;

		const forwarded = new Request(request);
		if (original) {
			forwarded.headers.set("Accept-Encoding", original);
		}

		// The gateway's cache is disabled (see the Wrangler configuration
		// above), so it runs on every request and always restores
		// Accept-Encoding before forwarding to the cached entrypoint.
		return ctx.exports.CachedAssets.fetch(forwarded);
	},
};

async function loadBrotli(request) {
	// Replace with your real asset loader (R2, KV, fetch, and so on).
	return { body: new ArrayBuffer(0), encoding: "br" };
}

async function loadGzip(request) {
	return { body: new ArrayBuffer(0), encoding: "gzip" };
}
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";

export class CachedAssets extends WorkerEntrypoint {
	async fetch(request: Request): Promise<Response> {
		const accept = request.headers.get("Accept-Encoding") ?? "";
		const wantsBrotli = accept.includes("br");

		const { body, encoding } = wantsBrotli
			? await loadBrotli(request)
			: await loadGzip(request);

		return new Response(body, {
			headers: {
				"Content-Type": "application/javascript",
				"Content-Encoding": encoding,
				"Cache-Control": "public, max-age=86400, immutable",
				// One variant per distinct Accept-Encoding value the cached
				// entrypoint sees. The gateway below normalizes that value.
				Vary: "Accept-Encoding",
			},
		});
	}
}

export default {
	async fetch(request, env, ctx): Promise<Response> {
		// On Cloudflare, the eyeball's Accept-Encoding is usually rewritten
		// to a canonical value before the Worker runs. Restore it from
		// request.cf.clientAcceptEncoding so the cached entrypoint sees
		// what the client actually sent — and so Vary keys variants on
		// the real value.
		const original = request.cf?.clientAcceptEncoding;

		const forwarded = new Request(request);
		if (original) {
			forwarded.headers.set("Accept-Encoding", original);
		}

		// The gateway's cache is disabled (see the Wrangler configuration
		// above), so it runs on every request and always restores
		// Accept-Encoding before forwarding to the cached entrypoint.
		return ctx.exports.CachedAssets.fetch(forwarded);
	},
} satisfies ExportedHandler;

async function loadBrotli(
	request: Request,
): Promise<{ body: ArrayBuffer; encoding: string }> {
	// Replace with your real asset loader (R2, KV, fetch, and so on).
	return { body: new ArrayBuffer(0), encoding: "br" };
}

async function loadGzip(
	request: Request,
): Promise<{ body: ArrayBuffer; encoding: string }> {
	return { body: new ArrayBuffer(0), encoding: "gzip" };
}

Things to notice:

  • The gateway runs on every request, but it is small. It only restores one header and calls ctx.exports. The expensive work — picking the encoding, loading the asset — runs only on cache misses.
  • Variants share a single purge identity. Purging by tag or path prefix invalidates every variant of a URL together, so all variants must use the same Cache-Tag values. Refer to the notes in Content negotiation with Vary.
  • The same pattern applies to other normalizable headers. If you want to vary on Accept-Language and you receive a long, complex value from browsers, normalize it in the gateway (for example, fold it down to the primary language tag) before forwarding. This keeps the cache fan-out bounded.

If you do not need per-encoding variants — for example, if your Worker always returns Brotli when the client accepts it and otherwise falls back to gzip — you do not need Vary at all. Pick a canonical encoding inside the cached entrypoint based on the restored Accept-Encoding, and let the cache store a single variant. Refer to Accept-Encoding and Content-Encoding for that variant of the pattern.

So far the inner entrypoint has been a function of the request. The next example puts a stateful component — a Durable Object — behind the same cache stage, with the same shape.

Cache Durable Object responses

Durable Objects are never cached directly by Workers Caching — they are stateful, and caching their responses would defeat the point. But many Durable Object endpoints serve read-heavy traffic where a short cache TTL is perfectly acceptable: leaderboards, counters, aggregated stats, configuration that changes a few times an hour.

You can cache those responses by wrapping the Durable Object behind a named entrypoint and letting Workers Caching sit in front of the entrypoint. On a cache hit, the wrapper never runs and the Durable Object is never touched. Disable caching on the default (router) entrypoint and enable it on the CachedLeaderboard wrapper — the Durable Object itself is never cached and needs no cache configuration:

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

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.CachedLeaderboard]
type = "worker"

  [exports.CachedLeaderboard.cache]
  enabled = true
src/index.jsjs
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";

// A Durable Object that maintains an expensive-to-compute leaderboard.
export class Leaderboard extends DurableObject {
	async fetch(request) {
		const url = new URL(request.url);

		if (url.pathname === "/top") {
			const top = await this.computeTop();
			return new Response(JSON.stringify(top), {
				headers: { "Content-Type": "application/json" },
			});
		}

		if (url.pathname === "/record" && request.method === "POST") {
			const { userId, score } = await request.json();
			await this.record(userId, score);
			return new Response("Recorded");
		}

		return new Response("Not found", { status: 404 });
	}

	async computeTop() {
		// Pretend this is expensive — a sorted scan of stored state, an
		// aggregation across many keys, a call to another service.
		return { top: [], computedAt: Date.now() };
	}

	async record(userId, score) {
		await this.ctx.storage.put(`score:${userId}`, score);
	}
}

// Cached entrypoint. Forwards GET /top to the Durable Object and tags
// the response so it can be purged when scores change.
export class CachedLeaderboard extends WorkerEntrypoint {
	async fetch(request) {
		const id = this.env.LEADERBOARD.idFromName("global");
		const stub = this.env.LEADERBOARD.get(id);
		const response = await stub.fetch(request);

		// Copy the body and headers into a new Response so we can attach
		// cache headers. The DO's body stream is consumed once here.
		return new Response(response.body, {
			status: response.status,
			headers: {
				...Object.fromEntries(response.headers),
				"Cache-Control": "public, max-age=30",
				"Cache-Tag": "leaderboard",
			},
		});
	}

	// Invalidate this entrypoint's cached leaderboard. purge() is scoped to
	// the entrypoint that calls it, so it must run inside CachedLeaderboard —
	// the entrypoint that owns the cached response. The gateway invokes this
	// over ctx.exports after a write.
	async invalidate() {
		await this.ctx.cache.purge({ tags: ["leaderboard"] });
	}
}

// Default entrypoint. Routes reads through the cached entrypoint
// and writes directly to the Durable Object, invalidating the cache on write.
export default {
	async fetch(request, env, ctx) {
		const url = new URL(request.url);

		if (request.method === "GET" && url.pathname === "/top") {
			// Read path — goes through Workers Caching. The router's cache is
			// disabled (see the Wrangler configuration above), so it runs on
			// every request. On a hit, CachedLeaderboard never runs and the
			// Durable Object is never touched.
			return ctx.exports.CachedLeaderboard.fetch(request);
		}

		if (request.method === "POST" && url.pathname === "/record") {
			// Write path — bypass the cached entrypoint, hit the Durable
			// Object directly, then ask CachedLeaderboard to invalidate its
			// own cache so the next read returns fresh data. The purge must
			// run inside CachedLeaderboard because purges are scoped to the
			// entrypoint that owns the cached response — a purge from this
			// gateway would target the gateway's (disabled) cache instead.
			const id = env.LEADERBOARD.idFromName("global");
			const stub = env.LEADERBOARD.get(id);
			const result = await stub.fetch(request);

			await ctx.exports.CachedLeaderboard.invalidate();

			return result;
		}

		return new Response("Not found", { status: 404 });
	},
};
src/index.tsts
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";

interface Env {
	LEADERBOARD: DurableObjectNamespace<Leaderboard>;
}

// A Durable Object that maintains an expensive-to-compute leaderboard.
export class