Your Worker can delete its own cached responses at any time with purge(), or mark them stale with invalidate() so that the cache revalidates them with your Worker on the next request. Both are useful when data changes and the new value is more important than the performance benefit of continuing to serve the cached response — for example, after a content update, a user action, or a webhook from an upstream system.
Because Workers Caching is your Worker's cache, purging and invalidating are scoped to the Worker that owns the cache. Within a Worker, both are further scoped to the entrypoint that called purge() or invalidate(). A Worker cannot reach into another Worker's cache, an entrypoint cannot reach into another entrypoint's cache, and no zone-level purge (via the dashboard, API, or Terraform) affects Workers Caching content.
There are two equivalent ways to trigger a purge from inside your Worker:
ctx.cache.purge(...)— available on the execution context passed to every handler. Use this when you already havectxin scope.cache.purge(...)— imported fromcloudflare:workers. Use this when you want to call purge from code that does not receivectx— for example, a utility module shared across multiple handlers, or a framework adapter that does not thread the execution context through its internals.
Both forms call into the same API and behave identically. Pick whichever reads more cleanly for your code.
import { cache } from "cloudflare:workers";
export default {
async fetch(request, env, ctx) {
// Using the module import — no need to thread ctx through helper functions.
await cache.purge({ tags: ["blog-posts"] });
// Equivalent, using ctx directly:
// await ctx.cache.purge({ tags: ["blog-posts"] });
return new Response("Purged", { status: 200 });
},
};import { cache } from "cloudflare:workers";
export default {
async fetch(request, env, ctx): Promise<Response> {
// Using the module import — no need to thread ctx through helper functions.
await cache.purge({ tags: ["blog-posts"] });
// Equivalent, using ctx directly:
// await ctx.cache.purge({ tags: ["blog-posts"] });
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;The rest of this page uses ctx.cache.purge(...) in most examples because those examples already have ctx in scope. If you prefer the import form, substitute cache.purge(...) — nothing else changes.
purge() accepts either purgeEverything: true on its own, or one or both of tags and pathPrefixes:
| Field | Purges | Scope |
|---|---|---|
tags |
Every cached response tagged with one of the given values via Cache-Tag. |
Per entrypoint |
pathPrefixes |
Every cached response whose request path starts with one of the given prefixes. | Per entrypoint |
purgeEverything |
Every cached response for the entrypoint that called purge(). |
Per entrypoint |
purgeEverything is exclusive — combine tags and pathPrefixes in a single call if you want, but do not pass either alongside purgeEverything.
All three modes are scoped to the entrypoint that called purge(). A purge from PublicAPI does not affect cached responses stored by AdminAPI, even if they share tag names or path prefixes. To purge across every entrypoint of a Worker, call purge() from each entrypoint.
The returned promise resolves to a result object you can inspect to confirm success or handle failures — see Return value.
Purge after a write by calling ctx.cache.purge() at the end of any handler that mutates data:
export default {
async fetch(request, env, ctx) {
if (request.method === "POST") {
const body = await request.json();
// Mutate your data source (D1, KV, an origin, and so on), then purge
// every cached response tagged for this post.
await ctx.cache.purge({
tags: [`post-${body.postId}`, "post-list"],
});
return new Response("Updated", { status: 200 });
}
// Handle cacheable reads here.
return new Response("Hello", {
headers: { "Cache-Control": "public, max-age=3600" },
});
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
if (request.method === "POST") {
const body = await request.json<{ postId: string }>();
// Mutate your data source (D1, KV, an origin, and so on), then purge
// every cached response tagged for this post.
await ctx.cache.purge({
tags: [`post-${body.postId}`, "post-list"],
});
return new Response("Updated", { status: 200 });
}
// Handle cacheable reads here.
return new Response("Hello", {
headers: { "Cache-Control": "public, max-age=3600" },
});
},
} satisfies ExportedHandler;You can combine fields in a single call. For example, purge({ tags: ["blog-posts"], pathPrefixes: ["/blog/"] }) purges everything that matches either tag or path-prefix — the fields are unioned, not intersected. Use this when one logical change affects responses tagged by multiple schemes.
export default {
async fetch(request, env, ctx) {
// Combined call: purges everything tagged "blog-posts" AND
// everything under /blog/ in a single round-trip.
await ctx.cache.purge({
tags: ["blog-posts"],
pathPrefixes: ["/blog/"],
});
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
// Combined call: purges everything tagged "blog-posts" AND
// everything under /blog/ in a single round-trip.
await ctx.cache.purge({
tags: ["blog-posts"],
pathPrefixes: ["/blog/"],
});
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;Tags are attached to responses via the Cache-Tag response header, and purged later by name. This is the most flexible and commonly used purge method.
export default {
async fetch(request) {
const url = new URL(request.url);
const postId = url.pathname.split("/").pop() ?? "unknown";
const body = { id: postId, title: `Post ${postId}` };
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
"Cache-Tag": `post,post-${postId},blog`,
},
});
},
};export default {
async fetch(request): Promise<Response> {
const url = new URL(request.url);
const postId = url.pathname.split("/").pop() ?? "unknown";
const body = { id: postId, title: `Post ${postId}` };
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
"Cache-Tag": `post,post-${postId},blog`,
},
});
},
} satisfies ExportedHandler;The Cache-Tag header value is a comma-separated list of tags. Cloudflare strips this header before returning the response to clients.
Tag values must be printable ASCII (no spaces, no Unicode), each tag is at most 1024 characters long, and a response can carry up to 1000 tags. Tag matching at purge time is case-insensitive, so Foo and foo purge the same set of responses. Invalid tags are silently dropped at storage time — the response is still cached with the remaining valid tags. Refer to Cache tag limits for the full list.
export default {
async fetch(request, env, ctx) {
const postId = new URL(request.url).searchParams.get("id");
if (!postId) return new Response("Missing id", { status: 400 });
await ctx.cache.purge({ tags: [`post-${postId}`] });
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
const postId = new URL(request.url).searchParams.get("id");
if (!postId) return new Response("Missing id", { status: 400 });
await ctx.cache.purge({ tags: [`post-${postId}`] });
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;Tags are scoped to the entrypoint that called purge(). A tag named user-42 applied to responses in two different entrypoints is not purged by a single purge({ tags: ["user-42"] }) call — it only affects the entrypoint the call originated from. If you need to purge the same tag across several entrypoints, call purge() from each entrypoint, or centralize purge calls in a shared entrypoint that caches every response you later need to purge.
To purge groups of related responses in one call, tag each response with multiple tags representing every level of hierarchy it belongs to — sometimes called "soft tags":
export default {
async fetch(request) {
const path = new URL(request.url).pathname;
// Build a list of hierarchical tags for the current path.
// A response at /blog/2025/02/hello gets tags for:
// _path:/blog/, _path:/blog/2025/, _path:/blog/2025/02/, _path:/blog/2025/02/hello
const segments = path.split("/").filter(Boolean);
const tags = segments.map(
(_, i) => `_path:/${segments.slice(0, i + 1).join("/")}/`,
);
const body = `<!doctype html><title>${path}</title>`;
return new Response(body, {
headers: {
"Content-Type": "text/html",
"Cache-Control": "public, max-age=3600",
"Cache-Tag": tags.join(","),
},
});
},
};export default {
async fetch(request): Promise<Response> {
const path = new URL(request.url).pathname;
// Build a list of hierarchical tags for the current path.
// A response at /blog/2025/02/hello gets tags for:
// _path:/blog/, _path:/blog/2025/, _path:/blog/2025/02/, _path:/blog/2025/02/hello
const segments = path.split("/").filter(Boolean);
const tags = segments.map(
(_, i) => `_path:/${segments.slice(0, i + 1).join("/")}/`,
);
const body = `<!doctype html><title>${path}</title>`;
return new Response(body, {
headers: {
"Content-Type": "text/html",
"Cache-Control": "public, max-age=3600",
"Cache-Tag": tags.join(","),
},
});
},
} satisfies ExportedHandler;Purging the tag _path:/blog/2025/ then deletes every cached response whose URL starts with /blog/2025/.
For limits on the number, length, and character set of tags, refer to Cache tag limits.
By default, Workers Caching partitions the cache by Worker version, so each deployment already starts from a cold cache and version-specific purging is unnecessary. This section applies only when you have enabled cache.cross_version_cache to share cached responses across versions. In that case, a response written by version A may still be served after version B is deployed, and you may want to purge the entries a specific version wrote — for example, after a rollback. To do that, tag each response with the version that produced it and purge that tag later.
Add the version metadata binding to your Wrangler configuration:
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-10-05",
"cache": { "enabled": true, "cross_version_cache": true },
"version_metadata": { "binding": "CF_VERSION_METADATA" },
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-10-05"
[cache]
enabled = true
cross_version_cache = true
[version_metadata]
binding = "CF_VERSION_METADATA"Then prepend the version ID to your tags:
export default {
async fetch(request, env, ctx) {
const { id: versionId } = env.CF_VERSION_METADATA;
const postId = new URL(request.url).pathname.split("/").pop() ?? "unknown";
return new Response(JSON.stringify({ id: postId }), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
// Include the version ID as a tag so you can purge by version later.
"Cache-Tag": `post,post-${postId},v:${versionId}`,
},
});
},
};interface Env {
CF_VERSION_METADATA: WorkerVersionMetadata;
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const { id: versionId } = env.CF_VERSION_METADATA;
const postId = new URL(request.url).pathname.split("/").pop() ?? "unknown";
return new Response(JSON.stringify({ id: postId }), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
// Include the version ID as a tag so you can purge by version later.
"Cache-Tag": `post,post-${postId},v:${versionId}`,
},
});
},
} satisfies ExportedHandler<Env>;When you want to purge everything a specific version wrote — for example, after a rollback — purge the version tag:
export default {
async fetch(request, env, ctx) {
const versionId = new URL(request.url).searchParams.get("version");
if (!versionId) return new Response("Missing version", { status: 400 });
await ctx.cache.purge({ tags: [`v:${versionId}`] });
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
const versionId = new URL(request.url).searchParams.get("version");
if (!versionId) return new Response("Missing version", { status: 400 });
await ctx.cache.purge({ tags: [`v:${versionId}`] });
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;pathPrefixes purges every cached response whose request path begins with one of the given prefixes:
export default {
async fetch(request, env, ctx) {
// Purge everything under /blog/2025/ for the current entrypoint.
await ctx.cache.purge({
pathPrefixes: ["/blog/2025/"],
});
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
// Purge everything under /blog/2025/ for the current entrypoint.
await ctx.cache.purge({
pathPrefixes: ["/blog/2025/"],
});
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;Entries in pathPrefixes are paths, not full URLs. A prefix must not include a scheme, host, query string, or fragment — passing something like https://example.com/blog/ is invalid input, not a prefix that simply fails to match. A leading slash is optional (/images and images are treated the same) but recommended for clarity.
pathPrefixes is scoped to the entrypoint that makes the purge call. A purge({ pathPrefixes: ["/blog/"] }) from PublicAPI will not affect cached responses stored by AdminAPI, even when their paths also start with /blog/.
There is no dedicated "purge by URL" mode. To purge a single cached URL, pass its path as a single-element pathPrefixes array:
export default {
async fetch(request, env, ctx) {
// Purge the cached response for exactly /blog/2026/hello-world.
await ctx.cache.purge({
pathPrefixes: ["/blog/2026/hello-world"],
});
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
// Purge the cached response for exactly /blog/2026/hello-world.
await ctx.cache.purge({
pathPrefixes: ["/blog/2026/hello-world"],
});
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;Because pathPrefixes matches on the start of the request path, passing the full path matches only that path — plus any paths that happen to extend it (for example, /blog/2026/hello-world-2). If you need exact-match semantics with no risk of over-purge, use a tag instead.
Delete every cached response stored by the calling entrypoint:
export default {
async fetch(request, env, ctx) {
await ctx.cache.purge({ purgeEverything: true });
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
await ctx.cache.purge({ purgeEverything: true });
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;Use this sparingly. Purging everything causes all subsequent requests to miss the cache until they can be re-filled, which temporarily increases load on your Worker and any upstream services it calls.
invalidate() is the soft counterpart of purge(). It accepts the same options (tags, pathPrefixes, or purgeEverything), selects cached responses the same way, follows the same per-entrypoint scoping, and resolves to the same result object. You can call it as ctx.cache.invalidate(...) or as cache.invalidate(...) imported from cloudflare:workers — the same two forms as purge().
The two methods differ in what happens to the matching cached responses:
| Method | Matching cached responses | Next matching request |
|---|---|---|
purge() |
Deleted | Cache miss. Your Worker runs and produces a fresh response. |
invalidate() |
Kept, but marked stale | Revalidated. The cache sends your Worker a conditional request instead. |
To revalidate an invalidated response, the cache calls your Worker with conditional request headers built from the validators stored with the response — for example, If-None-Match carrying the cached ETag, or If-Modified-Since carrying the cached Last-Modified. If your Worker answers 304 Not Modified, the cache keeps the stored body and serves it with Cf-Cache-Status: REVALIDATED, and later requests are HITs again. If your Worker answers with a full 200 response instead, that response replaces the cached one.
invalidate() therefore saves work only when your Worker emits validators (ETag or Last-Modified) and answers matching conditional requests with 304. This helps most when one call covers many responses but only some of them changed. Each unchanged response then costs a validator check instead of a full regeneration. If your Worker does not emit validators, or always answers with a full response, every revalidation regenerates the response and invalidate() saves no work compared to purge().
In this example, a catalog sync writes every product and then invalidates all product pages. Each product carries a revision that changes only when its data changes, and the Worker uses that revision as the ETag. Pages for unchanged products revalidate with 304, so the Worker renders only the pages that changed:
function renderProductPage(product) {
// Stands in for the expensive rendering work that a 304 response skips.
return `<!doctype html><title>${product.name}</title>`;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/sync") {
// Write every product from the upstream catalog. Many are unchanged.
const products = await request.json();
await Promise.all(
products.map((product) =>
env.PRODUCTS.put(product.id, JSON.stringify(product)),
),
);
// Mark every cached product page stale instead of deleting it.
await ctx.cache.invalidate({ tags: ["products"] });
return new Response("Synced", { status: 200 });
}
const id = url.pathname.split("/").pop() ?? "";
const product = await env.PRODUCTS.get(id, "json");
if (!product) return new Response("Not found", { status: 404 });
const etag = `"${product.revision}"`;
const headers = {
"Cache-Control": "public, max-age=86400",
"Cache-Tag": `products,product-${id}`,
ETag: etag,
};
// To revalidate an invalidated response, the cache sends the stored ETag
// in If-None-Match. If the product has not changed, answer 304 and skip
// rendering. The cache keeps the body it already has.
if (request.headers.get("If-None-Match") === etag) {
return new Response(null, { status: 304, headers });
}
return new Response(renderProductPage(product), {
headers: { ...headers, "Content-Type": "text/html" },
});
},
};interface Env {
PRODUCTS: KVNamespace;
}
interface Product {
id: string;
name: string;
// Changes only when the product data changes.
revision: string;
}
function renderProductPage(product: Product): string {
// Stands in for the expensive rendering work that a 304 response skips.
return `<!doctype html><title>${product.name}</title>`;
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/sync") {
// Write every product from the upstream catalog. Many are unchanged.
const products = await request.json<Product[]>();
await Promise.all(
products.map((product) =>
env.PRODUCTS.put(product.id, JSON.stringify(product)),
),
);
// Mark every cached product page stale instead of deleting it.
await ctx.cache.invalidate({ tags: ["products"] });
return new Response("Synced", { status: 200 });
}
const id = url.pathname.split("/").pop() ?? "";
const product = await env.PRODUCTS.get<Product>(id, "json");
if (!product) return new Response("Not found", { status: 404 });
const etag = `"${product.revision}"`;
const headers = {
"Cache-Control": "public, max-age=86400",
"Cache-Tag": `products,product-${id}`,
ETag: etag,
};
// To revalidate an invalidated response, the cache sends the stored ETag
// in If-None-Match. If the product has not changed, answer 304 and skip
// rendering. The cache keeps the body it already has.
if (request.headers.get("If-None-Match") === etag) {
return new Response(null, { status: 304, headers });
}
return new Response(renderProductPage(product), {
headers: { ...headers, "Content-Type": "text/html" },
});
},
} satisfies ExportedHandler<Env>;Purges triggered by ctx.cache.purge() use Cloudflare's Instant Purge infrastructure and propagate globally with the same guarantees as zone-level purges.
purge() and invalidate() resolve to the same result object. Check success to confirm the request was accepted, and inspect errors if it was not:
export default {
async fetch(request, env, ctx) {
const result = await ctx.cache.purge({ tags: ["blog-posts"] });
if (!result.success) {
console.error("Cache purge failed", result.errors);
return new Response("Purge failed", { status: 500 });
}
return new Response("Purged", { status: 200 });
},
};export default {
async fetch(request, env, ctx): Promise<Response> {
const result = await ctx.cache.purge({ tags: ["blog-posts"] });
if (!result.success) {
console.error("Cache purge failed", result.errors);
return new Response("Purge failed", { status: 500 });
}
return new Response("Purged", { status: 200 });
},
} satisfies ExportedHandler;On failure, each error in errors carries a numeric code and a human-readable message you can log or surface to the caller.
purge() uses the same rate-limiting system as Cloudflare's zone purge API. However, because Workers Caching is associated with the Worker rather than the zone, Workers Cache always uses the Free tier limits described in Availability and limits, regardless of your account or zone plan. When the purge is rate-limited, success is false and errors contains an entry describing the rejection.