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.

Cloudflare purge by Cache-Tag A product update calls the purge_cache API with a tag; Cloudflare evicts every response carrying that Cache-Tag across PoPs. PUT /product/123 price changed POST purge_cache { tags: ["product:123"] } Cloudflare edge cache /product/123 /category/7 /home
One tagged purge evicts every response that declared the tag, with no URL enumeration.

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-Tag indexing 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:123 but the purger sends products-123, the purge silently does nothing. Build both from one shared helper.
  • Blocking the response on the purge. Awaiting purgeByTags adds the API round-trip to the user’s mutation latency. Use ctx.waitUntil.
  • Retrying 4xx. A 403 means a bad token or wrong permission; retrying just wastes the waitUntil budget. Retry only 429 and 5xx.
  • 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/5xx and 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.