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