Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

最終更新 Markdown で表示Agent セットアップ

Workers Caching は Worker のプリミティブそのものであるキャッシュ です。すべての Worker エントリポイント — デフォルトエクスポートと、名前付きの WorkerEntrypoint — の手前に置かれます。同じ Worker 内のエントリポイント間の fetch() 呼び出しの手前にも、ctx.exports 経由で置かれます。この後者の事実が、このページの残りの内容を可能にします。

あるエントリポイントが ctx.exports 経由で別のエントリポイントの fetch() を呼ぶと、キャッシュはその呼び出しを、ブラウザーからのリクエストと同じように評価します。ヒットなら、呼び出し先を実行せずにキャッシュ済みレスポンスを返します。ミスなら、呼び出し先を実行し、レスポンスをそのキャッシュキーで保存します。キーは呼び出し先のエントリポイント、パス、クエリ文字列、ctx.props です。呼び出し元は毎回実行されます。ただし、呼び出し元が呼び出し先に渡す処理は、独立してキャッシュできます。

これで、組み合わせ可能なプリミティブが手に入ります。Worker を小さなエントリポイントの連鎖として書けます — 認証、正規化、ルーティング、コストの高い読み取り、データ層 — そして Workers Caching を必要な場所に差し込めます。キャッシュされる各エントリポイントは、独自のキー、独自の TTL、パージ用の独自のタグ名前空間を持つメモ化の単位です。キャッシュについて設定したいこと — いつ動くか、何をキーにするか、いつ無効化するか — は、通常の Worker コードとして表します。どのエントリポイントを呼ぶか、どのリクエストを転送するか、どの ctx.props を渡すか、どの Cache-Control を設定するかです。

このページの例は、どれも同じ形です。毎回実行される外側(ゲートウェイ)のエントリポイントと、キャッシュされる 1 つ以上の内側のエントリポイントです。外側は安い処理(認証、ヘッダー書き換え、ルート選択)をします。内側は高い処理(データの取得、変換、Durable Object の実行)をします。クラスとして 1 つのソースファイルに書き、1 つの Worker としてデプロイし、1 つの Worker として課金されます。内側のエントリポイントの手前にあるキャッシュ段でつながっています。

覚えておく 2 つのルール

以下のパターンは、どれも次の 2 点で決まります。「キャッシュはすべてのエントリポイントの手前にある」から直接導けます。

ゲートウェイエントリポイントではキャッシュを無効にします。 キャッシュはデフォルトですべてのエントリポイントの手前にあるため、外側のエントリポイント自体もキャッシュされます。次のリクエストは外側のキャッシュから返され、ゲートウェイのロジックに入りません。Wrangler 設定でゲートウェイエントリポイントのキャッシュをオフにし、ゲートウェイが転送する内側のエントリポイントではオンのままにします。デフォルトエクスポートには "default" を使います。

{
	"name": "my-worker",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-20",
	"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-20"

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.Inner]
type = "worker"

  [exports.Inner.cache]
  enabled = true

バイパスを強制するリクエストヘッダーは取り除きます。 Cloudflare の標準 バイパスルール は、内側のエントリポイントのキャッシュにも適用されます。転送するリクエストに Authorization ヘッダーがあると、内側の呼び出しはすべて BYPASS になり、何も保存されません。外側のエントリポイントがリクエストを認証し、キャッシュしてよいと判断したら、内側のエントリポイントを呼ぶ前に Authorization(および自動バイパスを引き起こすもの)を取り除く必要があります。

どちらのルールも、以下の例すべてに当てはまります。

認証済みレスポンスをキャッシュする

認証済み API のキャッシュは、これまで扱いにくいものでした。標準の バイパスルール は、Authorization ヘッダー付きのリクエストをプライベートとみなし、キャッシュしません。安全なデフォルトですが、トークン認証のエンドポイントが何千人のユーザーに同じレスポンスを返す場合でも、毎回 Worker が動きます。

次のパターンでは、毎回認証しつつ、キャッシュ可能なハンドラーを動かさずにキャッシュヒットを返せます。

  1. 外側(デフォルト)のエントリポイントがリクエストを受け取り、認証します。
  2. 成功したら、Authorization ヘッダーを取り除き、ctx.exports 経由で名前付きエントリポイントに転送します。
  3. Workers Caching は名前付きエントリポイントの手前にあります。ヒットなら、キャッシュ済みレスポンスが外側のエントリポイントに返り、クライアントに返されます。名前付きエントリポイントは実行されません。

デフォルトエントリポイントではキャッシュを無効にして、毎回認証できるようにします。CachedAPI では有効のままにします。

{
	"name": "my-worker",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-20",
	"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-20"

[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() };
}

次の点に注目してください。

  • キャッシュの位置が適切です。 外側のエントリポイントとキャッシュ対象のエントリポイントの間にあります。キャッシュヒットでは、コストの高い処理を完全に飛ばします。動くのは認証チェックだけです。
  • 転送前に Authorization を取り除きます。 これでレスポンスがキャッシュ可能になります。Cloudflare のバイパスルールはレスポンスではなく、着信リクエストで発火します。キャッシュ対象のエントリポイントに届く前にヘッダーを外すことで、そのエントリポイントの Cache-Control: public が効きます。トークンが将来のキャッシュキーに混ざることも防ぎます。
  • キャッシュ済みレスポンスはユーザー間で共有されます。 認証を通った呼び出し元は、同じキャッシュ済み本文を見ます。

ユーザーごとの認証済みレスポンス

エンドポイントがユーザー固有のデータを返す場合は、ユーザー識別子を ctx.props 経由で渡します。Workers Caching は ctx.props をキャッシュキーに含めるため、ユーザーごとにキャッシュエントリが分かれ、あるユーザーが別のユーザーのキャッシュ済みレスポンスを受け取ることはありません。Wrangler 設定は前の例と同じです。default ではキャッシュ無効、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() };
}

呼び出し元間のキャッシュ分離について詳しくは、ctx.props によるマルチテナントの安全性 を参照してください。

この例の形 — 外側のエントリポイントが値(ユーザーの識別情報)を ctx.props 経由でキャッシュキーに載せる — は、次の例がキーの別の部分に影響を与えるときと同じ形です。

Vary 向けに Accept-Encoding を正規化する

Vary を使うと、1 つの URL で複数の表現をキャッシュできます。たとえば、同じアセットの Brotli 版と gzip 版です。Cloudflare はバリアントを、Vary に列挙された各リクエストヘッダーの そのままの値 でキー付けします。意味は同じでも文字列が違う Accept-Encoding ヘッダーは、別々のバリアントになります。

Cloudflare のフロントライン経由のリクエストでは、さらに重要です。Worker が見る Accept-Encoding リクエストヘッダーは、キャッシュ効率のため、Cloudflare によって正規値(gzip, br など)に書き換えられているのが普通です。元の値は request.cf.clientAcceptEncoding に残ります。ただし、クライアントの値を戻さずに Accept-Encoding で Vary すると、キャッシュ済みバリアントはすべて書き換え後の文字列でキー付けされます。gzip しか受け付けないクライアントに Brotli バリアントが返る、あるいはその逆が起きます。

対処は、キャッシュ対象のエントリポイントに転送する前に、ゲートウェイエントリポイントで request.cf.clientAcceptEncoding から Accept-Encoding を戻すことです。ゲートウェイではキャッシュを無効にし、CachedAssets では有効にします。

{
	"name": "my-worker",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-20",
	"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-20"

[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" };
}

注目点です。

  • ゲートウェイは毎回動きますが、小さいです。 ヘッダーを 1 つ戻して ctx.exports を呼ぶだけです。コストの高い処理 — エンコーディングの選択、アセットの読み込み — はキャッシュミスのときだけ動きます。
  • バリアントは 1 つのパージ識別を共有します。 タグやパスプレフィックスでのパージは、URL のすべてのバリアントをまとめて無効化します。そのため、すべてのバリアントで同じ Cache-Tag 値を使う必要があります。Vary によるコンテンツネゴシエーション の注意も参照してください。
  • 同じパターンは、ほかの正規化可能なヘッダーにも使えます。 Accept-Language で Vary したいとき、ブラウザーから長く複雑な値が来るなら、転送前にゲートウェイで正規化します(例: 主要言語タグに畳む)。キャッシュの扇出を抑えられます。

エンコーディングごとのバリアントが不要なら — たとえば、クライアントが受け付けるときは常に Brotli を返し、それ以外は gzip にフォールバックするなら — Vary は不要です。戻した Accept-Encoding に基づいてキャッシュ対象のエントリポイント内で正規のエンコーディングを選び、キャッシュには 1 つのバリアントだけを保存します。そのパターンは Accept-EncodingContent-Encoding を参照してください。

ここまでの内側のエントリポイントは、リクエストの関数でした。次の例では、同じキャッシュ段の後ろに、ステートフルなコンポーネント — Durable Object — を置きます。形は同じです。

Durable Object のレスポンスをキャッシュする

Durable Objects は、Workers Caching では直接キャッシュされません。ステートフルであり、レスポンスをキャッシュすると意味がなくなるためです。ただし、多くの Durable Object エンドポイントは読み取り中心で、短いキャッシュ TTL で十分な場合があります。リーダーボード、カウンター、集計統計、1 時間に数回しか変わらない設定などです。

そうしたレスポンスは、Durable Object を名前付きエントリポイントで包み、その手前に Workers Caching を置くことでキャッシュできます。キャッシュヒットでは、ラッパーは動かず、Durable Object にも触れません。デフォルト(ルーター)のエントリポイントではキャッシュを無効にし、CachedLeaderboard ラッパーでは有効にします。Durable Object 自体はキャッシュされず、キャッシュ設定も不要です。

{
	"name": "my-worker",
	"main": "src/index.ts",
	// Set this to today's date
	"compatibility_date": "2026-09-20",
	"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-20"

[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 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);
		}

		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 });
	},
} satisfies ExportedHandler<Env>;

うまくいく理由です。

  • キャッシュヒットでは、読み取りのコストはほぼありません。 Workers Caching は CachedLeaderboard の手前にあるため、ヒットではキャッシュ済み本文を返し、ラッパーも Durable Object も、コストの高い集計も動きません。デフォルトエントリポイントはリクエストの振り分けのために動きますが、薄いルーターです。
  • 書き込みはキャッシュをすぐ無効化します。 POST ハンドラーは Durable Object を更新したあと、ctx.exports.CachedLeaderboard.invalidate() を呼びます。これは CachedLeaderboard内側purge({ tags: ["leaderboard"] }) を実行します。これが重要なのは、パージは呼び出したエントリポイントにスコープされる ためです。ゲートウェイのキャッシュは無効なので、ゲートウェイから出したパージは CachedLeaderboard が保存したエントリに届きません。直後の GET はキャッシュミスし、ラッパーが再実行され、新しいレスポンスが保存されます。
  • キャッシュ契約は、キャッシュ対象のエントリポイントが持ちます。 キャッシュ制御ヘッダーはすべて CachedLeaderboard で設定します。Cache-Tag も含みます。invalidate() メソッドも CachedLeaderboard が公開し、それらをパージします。Durable Object はキャッシュを知りません。

独立した Durable Object インスタンスが多数ある場合 — テナントごとに 1 つなど — キャッシュ対象のエントリポイントを呼ぶとき、ユーザーごとの認証済みレスポンス と同じように、テナント識別子を ctx.props 経由で渡します。テナントごとにキャッシュエントリが分かれ、あるテナントへのパージはほかを無効化しません。

管理していないオリジンをキャッシュする

依存するオリジンが自前でないことがあります。サードパーティ API、SaaS エンドポイント、公開データセット、遅い CDN の先にあるベンダーサービス — キャッシュヘッダーは所有者が決めたままです。変えられません。安全のため Cache-Control: no-store を送っているかもしれません。何も送っていないかもしれません。アプリケーションの読み取りパターンに合わないほど積極的にキャッシュしているかもしれません。いずれにせよ、呼び出しごとに遅延とリクエストコストを払います。

Workers Caching を使うと、オリジン側を変えずに、そのオリジンの手前に独自のキャッシュ層を置けます。パターンはこのページのほかと同じ、外側と内側の形です。オリジンに転送する薄いエントリポイントの手前に Workers Caching を置き、選んだ Cache-Control ディレクティブを適用します。オリジンは、ほかの世界とのキャッシュ契約はそのままです。Worker は、アプリケーションとそのオリジンの間に、ユーザーが制御する第 2 の層を足すだけです。ほかのパターンと同様、ゲートウェイではキャッシュを無効にし、CachedOrigin では有効にします。

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

[cache]
enabled = true

[exports.default]
type = "worker"

  [exports.default.cache]
  enabled = false

[exports.CachedOrigin]
type = "worker"

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

const ORIGIN = "https://api.example.com";

// Cached entrypoint. Fetches the upstream origin and overlays your own
// Cache-Control on the response. Workers Caching sits in front of this,
// so on a hit the upstream origin is never contacted.
export class CachedOrigin extends WorkerEntrypoint {
	async fetch(request) {
		const url = new URL(request.url);
		const upstream = new URL(url.pathname + url.search, ORIGIN);

		// Forward the request to the third-party origin. The origin's own
		// caching headers (or lack of them) are about to be overwritten —
		// they apply to the origin's relationship with the public internet,
		// not to your cache layer.
		const response = await fetch(upstream, {
			method: request.method,
			headers: request.headers,
			body: request.body,
		});

		// Replace the origin's Cache-Control with your own. This is the
		// whole point of the pattern: you decide how long Workers Caching
		// stores this response, regardless of what the origin says.
		const headers = new Headers(response.headers);
		headers.set("Cache-Control", "public, max-age=300");
		headers.set("Cache-Tag", "origin:example");

		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers,
		});
	}
}

// Default entrypoint. Forwards every request through the cached entrypoint.
export default {
	async fetch(request, env, ctx) {
		// The gateway's cache is disabled (see the Wrangler configuration
		// above), so it runs on every request and forwards to the cached
		// CachedOrigin entrypoint.
		return ctx.exports.CachedOrigin.fetch(request);
	},
};
src/index.tsts
import { WorkerEntrypoint } from "cloudflare:workers";

const ORIGIN = "https://api.example.com";

// Cached entrypoint. Fetches the upstream origin and overlays your own
// Cache-Control on the response. Workers Caching sits in front of this,
// so on a hit the upstream origin is never contacted.
export class CachedOrigin extends WorkerEntrypoint {
	async fetch(request: Request): Promise<Response> {
		const url = new URL(request.url);
		const upstream = new URL(url.pathname + url.search, ORIGIN);

		// Forward the request to the third-party origin. The origin's own
		// caching headers (or lack of them) are about to be overwritten —
		// they apply to the origin's relationship with the public internet,
		// not to your cache layer.
		const response = await fetch(upstream, {
			method: request.method,
			headers: request.headers,
			body: request.body,
		});

		// Replace the origin's Cache-Control with your own. This is the
		// whole point of the pattern: you decide how long Workers Caching
		// stores this response, regardless of what the origin says.
		const headers = new Headers(response.headers);
		headers.set("Cache-Control", "public, max-age=300");
		headers.set("Cache-Tag", "origin:example");

		return new Response(response.body, {
			status: response.status,
			statusText: response.statusText,
			headers,
		});
	}
}

// Default entrypoint. Forwards every request through the cached entrypoint.
export default {
	async fetch(request, env, ctx): Promise<Response> {
		// The gateway's cache is disabled (see the Wrangler configuration
		// above), so it runs on every request and forwards to the cached
		// CachedOrigin entrypoint.
		return ctx.exports.CachedOrigin.fetch(request);
	},
} satisfies ExportedHandler;

ここで起きていることです。

  • キャッシュ層は自前です。 オリジンの Cache-Control は、レスポンスが Workers Caching に届く前に置き換えられます。TTL、鮮度ディレクティブ、Cache-Tag の名前空間は、すべて自分のコードで制御します。キャッシュがレスポンスをどれだけ保持するか、ctx.cache.purge() でいつパージするかは、自分で決めます。
  • オリジン自身のキャッシュモデルは変わりません。 書き換えた Cache-Control を見るのは、自分の Worker だけです。オリジンは、公開したキャッシュ契約のままほかのクライアントに配信します。挙動もセキュリティモデルも変えていません。アプリケーション向けに手前の層を足しただけです。
  • キャッシュヒットではオリジンに触れません。 Workers Caching は CachedOrigin の手前にあるため、ヒットでは保存済みレスポンスを返し、上流への fetch は動きません。これでオリジンへのリクエスト量と、キャッシュ済み呼び出しの遅延が減ります。

このパターンのよくある拡張です。

  • リソースごとの TTL。 上流のパスごとに鮮度を変えたいなら、CachedOrigin 内で url.pathname で分岐し、それぞれ違う max-age(と違う Cache-Tag)を設定します。キャッシュキーにはすでにパスとクエリ文字列が含まれるため、リソースごとにエントリが分かれます。
  • ユーザーごとのキャッシュ。 アプリケーションが呼び出し元を認証し、上流がユーザー固有のデータを返すなら、外側のエントリポイントで認証し、ユーザー識別子を ctx.props 経由で CachedOrigin に渡します。ユーザーごとの認証済みレスポンス と同じ形です。ユーザーごとにキャッシュエントリが分かれ、あるユーザーが別のユーザーのキャッシュ済みレスポンスを受け取ることはありません。
  • Stale-while-revalidate。 オリジンが遅い、または不安定なら、キャッシュ済みレスポンスに Cache-Control: public, max-age=60, stale-while-revalidate=600 を設定します。大半のリクエストはキャッシュ済み本文をすぐ返し、Workers Caching はバックグラウンドでオリジンを更新します。詳しくは stale-while-revalidate で低遅延の更新を行う を参照してください。
  • 対象を絞った無効化。 アプリケーションのデータモデルを反映する Cache-Tag 値でレスポンスをタグ付けします(例: Cache-Tag: origin:example, product:42)。上流が変わったと分かったとき — webhook が発火した、管理操作が走った — ctx.cache.purge({ tags: ["product:42"] }) を呼び、次のリクエストでキャッシュを再構築します。

これは、このページのほかの例と同じ構成要素です。違うのは、キャッシュ対象のエントリポイントがミス時に行う「コストの高い処理」が、他人のサーバーへの fetch である点だけです。そのレスポンスをどれだけ保持するか、どうキー付けするか、いつ無効化するかの制御は、すべて自分の Worker に残ります。

パターンを組み合わせる

4 つの例は、同じアーキテクチャを 4 つの視点から見たものです。

外側のエントリポイント キャッシュ段の役割 内側のエントリポイント
リクエストを認証する ユーザーごとにコストの高い計算をキャッシュする ユーザーデータを読み込む、または計算する
Accept-Encoding を戻す 実際のエンコーディングごとに 1 つのバリアントをキャッシュする 正しいエンコーディングのアセットを読み込む
読み取りと書き込みを振り分ける 読み取りをキャッシュし、書き込み時に無効化する Durable Object を Cache-Tag の後ろに包む
リクエストをそのまま転送する サードパーティオリジンを自分の条件でキャッシュする 上流を取得し、Cache-Control を上書きする

行ごとに変わるのは、呼び出し前に外側が何をするかと、ミス時に内側が何をするかだけです。真ん中のキャッシュ段は毎回同じプリミティブです。内側のエントリポイント、リクエストのパスとクエリ文字列、ctx.props でキー付けされ、内側の Cache-ControlCache-Tag で設定され、データを持つエントリポイントからの ctx.cache.purge() で無効化されます。

この均一さがあるため、パターンは組み合わせられます。1 つの Worker に積み重ねても問題ありません。

  • 認証とルーティングをする外側のエントリポイント。
  • 追跡用クエリパラメーターを取り除き、Accept-Encoding を戻し、リクエストを正規形に整える正規化エントリポイント。
  • Durable Object の手前に立ち、パージ用にタグ付けするキャッシュ対象のエントリポイント。
  • 認証なしの公開エンドポイント用の、別のキャッシュ対象エントリポイント。同じ外側のエントリポイントからも到達でき、独自のキャッシュキーと Cache-Tag 名前空間を持ちます。

これらのエントリポイント間の各呼び出しは、それぞれ独自のキャッシュ段を通ります。連鎖は同じ 3 つの構成要素 — WorkerEntrypointctx.exportsCache-Control ヘッダー — でできています。キャッシュは、後付けの別システムではなく、連鎖の一段です。キャッシュルールエンジンで設定していたことは、コードとして書きます。どのエントリポイントが動くか、どのリクエストが転送されるか、どの props が渡されるか、どの Cache-Control が返るか、何がパージされるかです。

決まったパターン一覧はありません。Workers Caching は、すべての Worker エントリポイントの間にキャッシュを置きます。それで何を作るかは、自分次第です。

役に立ちましたか?