Purging Cloudflare Cache by Tag
This guide is part of Tag and Surrogate-Key Cache Invalidation at the Edge. It walks through one concrete task: tagging Cloudflare-cached responses with Cache-Tag and purging them precisely by tag when the underlying entity changes.
The problem
A product price changes. The product page, three category listings, and the homepage all render that price, and all four are cached at the edge. You do not have those four URLs in hand — they are derived from data — and a blanket cache purge would evict your entire zone and flood the origin. You need to evict exactly the responses that depend on that product, and nothing else.
Root cause: you cannot enumerate the cache, so you tag it
Cloudflare’s edge cache spans hundreds of PoPs and exposes no way to list keys. The only precise invalidation primitive is the Cache Tag: at write time you attach a Cache-Tag header naming the entities a response depends on, and at mutation time you call the zone purge API with those tag names. Cloudflare evicts every cached response indexed under the tag, network-wide. The purge call itself runs from your application, often via edge middleware on a mutating request, and must run off the hot path so the user’s write is not blocked. Cache Tags are a Cloudflare Enterprise feature.
Step 1: Attach Cache-Tag at write time
In your Worker (or origin), set a comma-separated Cache-Tag header on each cacheable response naming the entities it renders. Cloudflare strips this header before delivering to the client and uses it to build the tag index.
const tags = {
product: (id: string) => `product:${id}`,
category: (id: string) => `category:${id}`,
} as const;
function tagResponse(response: Response, tagList: string[]): Response {
const res = new Response(response.body, response);
// Cache-Tag is comma-separated; Cloudflare indexes each tag.
res.headers.set("Cache-Tag", tagList.join(","));
res.headers.set("Cache-Control", "public, max-age=300");
return res;
}
// Example: a product page response
function buildProductResponse(body: BodyInit, product: { id: string; categoryId: string }) {
return tagResponse(new Response(body), [
tags.product(product.id),
tags.category(product.categoryId),
]);
}
Step 2: Build an idempotent purge client
The purge call hits POST /zones/{zone_id}/purge_cache with a JSON body of tags. Wrap it in bounded retry so a transient failure does not leave content stale. Purges are idempotent, so retrying is always safe.
interface PurgeEnv {
CF_ZONE_ID: string;
CF_API_TOKEN: string; // secret with "Cache Purge" permission
}
async function purgeByTags(env: PurgeEnv, tagList: string[]): Promise<boolean> {
const url = `https://api.cloudflare.com/client/v4/zones/${env.CF_ZONE_ID}/purge_cache`;
for (let attempt = 1; attempt <= 4; attempt++) {
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: tagList }),
});
if (res.ok) return true;
// 4xx (except 429) is our mistake; do not retry.
if (res.status >= 400 && res.status < 500 && res.status !== 429) return false;
const backoff = Math.min(2 ** attempt * 100, 2000);
await new Promise((r) => setTimeout(r, backoff + Math.random() * 100));
}
return false;
}
Step 3: Trigger the purge on mutation, off the hot path
Only mutating requests purge. Gate the purge behind a method/route check and run it in ctx.waitUntil so the user’s write returns immediately.
export default {
async fetch(request: Request, env: PurgeEnv, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const match = url.pathname.match(/^\/api\/products\/([^/]+)$/);
if (match && ["PUT", "PATCH", "DELETE"].includes(request.method)) {
const id = match[1];
const response = await fetch(request); // forward the mutation to origin
const purgeTags = [tags.product(id)];
// Fire-and-forget: never block the response on tag propagation.
ctx.waitUntil(purgeByTags(env, purgeTags));
return response;
}
return fetch(request);
},
};
Configuration: wrangler and secrets
// wrangler.jsonc
{
"name": "cache-purge-worker",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"vars": { "CF_ZONE_ID": "" }
}
Store the API token as a secret, never in vars:
wrangler secret put CF_API_TOKEN
The token needs the Cache Purge permission scoped to the zone. A token with broader scope is an unnecessary blast radius.
Local vs production divergence
| Behavior | wrangler dev (local) |
Production |
|---|---|---|
Cache-Tag indexing |
No real edge cache; tags are not indexed | Tags indexed across PoPs |
purge_cache call |
Hits the real API if a token is present; mutates production cache | Same endpoint, real effect |
| Eviction visibility | Cannot observe local eviction | Confirm via cf-cache-status: MISS after purge |
| Enterprise gating | API rejects tags without an Enterprise zone | Requires Enterprise plan |
Because wrangler dev has no local edge cache, validate tag attachment and the purge request shape with unit tests, and validate eviction against a real preview zone by watching cf-cache-status flip from HIT to MISS after a purge.
Vitest validation
import { describe, expect, it, vi } from "vitest";
import { purgeByTags } from "./purge";
const env = { CF_ZONE_ID: "zone1", CF_API_TOKEN: "tok" };
describe("purgeByTags", () => {
it("posts tags to the zone purge endpoint", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await purgeByTags(env, ["product:123"]);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain("/zones/zone1/purge_cache");
expect(JSON.parse(init.body)).toEqual({ tags: ["product:123"] });
expect(init.headers.Authorization).toBe("Bearer tok");
});
it("retries on 5xx then succeeds", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("", { status: 503 }))
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(purgeByTags(env, ["product:1"])).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not retry on 403", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
await expect(purgeByTags(env, ["product:1"])).resolves.toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
Pitfalls
- Cache Tags require Enterprise.
Cache-Tagindexing and tag purges are an Enterprise-plan feature; on lower plans the tags are ignored. Confirm your plan before relying on them. - Tag string drift. If the writer emits
product:123but the purger sendsproducts-123, the purge silently does nothing. Build both from one shared helper. - Blocking the response on the purge. Awaiting
purgeByTagsadds the API round-trip to the user’s mutation latency. Usectx.waitUntil. - Retrying 4xx. A
403means a bad token or wrong permission; retrying just wastes thewaitUntilbudget. Retry only429and5xx. - Exceeding the per-request tag limit. Cloudflare caps tags per purge request (up to 1000) and each tag’s length. Batch large tag sets across requests.
Production deployment checklist
-
Cache-Tag - The same helper builds the tags sent to
- Purges run in
ctx.waitUntil - Purge retries cover
429/5xxand stop on
Frequently Asked Questions
Do I need a specific Cloudflare plan to purge by tag?
Yes. Cache-Tag indexing and tag-based purges are a Cloudflare Enterprise feature. On lower plans the Cache-Tag header is ignored, so confirm your plan before building on it. On other plans you can fall back to URL purges or key versioning.
How do I avoid the purge slowing down my write requests?
Run the purge in ctx.waitUntil so it executes after the response is returned. The user’s mutation completes immediately while tag propagation happens in the background. Pair this with bounded retries so transient failures still resolve.
Why did my purge return success but the page is still stale?
Usually the tag sent on purge does not exactly match the tag attached at write time. Build both from one shared helper so product:123 is identical on both paths, and log both tag sets to confirm. Also allow a few seconds for eviction to propagate across PoPs.
How many tags can I send in one purge request?
Cloudflare allows up to 1000 tags per purge request, with per-tag length limits. For larger invalidations, batch the tags across multiple requests rather than sending an oversized single call, which the API will reject with a 4xx.