Examples (13)·续1
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",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedAssets": { "type": "worker", "cache": { "enabled": true } },
},
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
[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-Tagvalues. Refer to the notes in Content negotiation withVary. - The same pattern applies to other normalizable headers. If you want to vary on
Accept-Languageand 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",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedLeaderboard": { "type": "worker", "cache": { "enabled": true } },
},
}
name = "my-worker"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
[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
```ts import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
interface Env { LEADERBOARD: DurableObjectNamespace<Leaderboard>; }
// A Durable Object that maintains an expensive-to-compute leaderboard. export class Leaderboard extends DurableObject<Env> { async fetch(request: Request): Promise<Response> { 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<{ userId: string; score: number; }>(); await this.record(userId, score); return new Response("Recorded"); }
return new Response("Not found", { status: 404 }); }
private async computeTop(): Promise<unknown> { // 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() }; }
private async record(userId: string, score: number): Promise<void> {
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<Env> { async fetch(request: Request): Promise<Response> { 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(): Promise<void> { 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): Promise<Response> { 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); }
