A cache that belongs to the Worker

Workers Cache introduces a tiered cache layer that sits in front of any Worker, turned on with a single line in Wrangler config and controlled with standard Cache-Control response headers.

Once enabled, every cacheable request to the Worker first checks Cloudflare's cache. A fresh hit returns the stored response without invoking your code — no CPU time, no latency from a render. A miss runs the Worker, and if the response is cacheable, that response is stored for subsequent requests from anywhere on the network.

BLOG-3262 image1

The entire configuration surface is one block:

{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-01",
  "cache": {
    "enabled": true
  }
}

From there, caching behavior is driven entirely by the headers you set on responses, and invalidation happens through your Worker's own purge calls:

return new Response(body, {
  headers: {
    "Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
    "Cache-Tag": "products,product:123",
  },
});
await ctx.cache.purge({ tags: ["product:123"] });

That's the whole API. No zone setup, no rules-engine configuration, no separate cache provisioning. The cache follows the Worker wherever it runs — custom domain, workers.dev, behind a service binding, in preview, or inside a Workers for Platforms tenant.

Flipping the architecture for server-rendered apps

When Workers launched in 2017, the runtime sat in front of the cache and origin, filtering traffic and transforming requests before they reached a backend. That model suited the original use cases — adding headers, rewriting URLs, or splitting traffic — where the Worker needed full control over what got cached.

BLOG-3262 image5

But the role of Workers changed. Frameworks such as Astro, Next.js, Remix, SvelteKit, and TanStack Start now compile applications directly into Workers, with no separate origin. The Worker itself is the server. In that scenario, the old architecture has nothing to subtract: every request triggers a code execution, even when the response is identical to one just served.

BLOG-3262 image9

Workers Cache places Cloudflare's cache in front of the Worker. On a hit, the Worker never starts, and CPU billing remains at zero. On a miss, it runs once to populate the cache, and subsequent requests are served directly from storage.

This opens a third path for server-rendered content beyond the old binary choice:

  • Prerender at build time: Fast loads, but every change means a full rebuild; small sites can take minutes, large ones far longer.
  • Render per request: Always fresh, but every visit pays the rendering cost and the latency.
  • Render on demand and cache the output: The first request renders; later ones are served as static content until the TTL expires, after which the next request re-renders.

With Workers Cache, no framework-specific revalidation machinery is needed — just HTTP caching in front of code written to act as an origin.

How stale-while-revalidate masks refresh latency

The stale-while-revalidate directive lets Cloudflare serve an expired cached copy immediately while the Worker refreshes the entry in the background. The first request after expiration gets the stale page instantly, marked with a Cf-Cache-Status: UPDATING header, while the Worker fills the cache with fresh output.

BLOG-3262 image3

The three-phase model:

  • Fresh window (max-age): cache serves the stored response; the Worker doesn't run.
  • Stale window (stale-while-revalidate): cache serves the stored response; the Worker refreshes in the background.
  • Outside both windows: the Worker runs synchronously to generate a fresh response.
 export default {
  async fetch(request) {
    const html = await renderPage(request);
    return new Response(html, {
      headers: {
        "Content-Type": "text/html; charset=utf-8",
        // Treat as fresh for 5 minutes; serve stale for up to an hour
        // while a background refresh runs.
        "Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
      },
    });
  },
};

The developer chooses the window sizes. A product catalog might use max-age=300 with stale-while-revalidate=3600; an archive that rarely changes can set max-age=86400 with a much longer revalidation period. Only the first request to an unseen page pays the full render cost.

Content negotiation with Vary

Applications frequently serve different representations from one URL — HTML versus JSON, WebP versus JPEG, or localized language variants. Workers Cache implements the standard HTTP Vary header to keep these straight, following RFC 9110 and RFC 9111.

When a response includes Vary: Accept (or any request header), Cloudflare maintains a separate cache variant for each distinct combination of values. Responses are only served to requests whose headers match the stored variant exactly.

export default {
  async fetch(request) {
    const accept = request.headers.get("Accept") ?? "";
    const wantsWebp = accept.includes("image/webp");

    const body = wantsWebp ? await fetchWebpImage() : await fetchJpegImage();

    return new Response(body, {
      headers: {
        "Content-Type": wantsWebp ? "image/webp" : "image/jpeg",
        "Cache-Control": "public, max-age=3600",
        // Cache a separate variant per distinct Accept header value.
        Vary: "Accept",
      },
    });
  },
};

There's no restrictively small allowlist of headers you may vary on. You specify whichever headers matter, and caching keys on the verbatim values. Certain documented edge cases apply: normalizing headers in a gateway Worker helps control variant fan-out, purging invalidates every variant of a URL simultaneously, and Vary: * disables caching for that response entirely.

A cache that belongs to the Worker

Cloudflare's existing cache has always been a zone-level construct. Cache Rules, Page Rules, file-extension lists, Cache Reserve, Tiered Cache — all of it is configured per zone, and a Worker operating on that zone had to either conform to that setup or find ways around it.

Workers Cache changes the model. It is the Worker's own cache, not the zone's, and the implications are significant:

  • No zone configuration applies. Cache Rules, cache level settings, Page Rules, and extension lists are all irrelevant. The Worker's own Cache-Control headers are the only configuration.
  • The cache follows the Worker, not the hostname. A single Worker bound to multiple hostnames or invoked via a service binding shares one cache across all entry points. A request to /users/42 returns the same cached entry regardless of which route it took.
  • It works everywhere Workers run. This includes workers.dev, preview URLs (each with an isolated cache so testing never pollutes production), and Workers for Platforms (where each user Worker gets its own tenant-isolated cache).
  • Purges are scoped to the Worker. Calling ctx.cache.purge({ purgeEverything: true }) only clears that Worker's cache — never the zone's other content, and never another Worker's data.

Cache configuration becomes code: set different max-age values per path, return Cache-Control: private to bypass entirely, or shape cache keys through ctx.props and URL normalization. The Worker itself is the entire configuration surface.

Two tiers, zero setup

Workers Cache is regionally tiered by default, with no opt-in required. Every Worker with caching enabled gets two layers automatically:

  • A lower tier in the data center closest to the user, with each populating data center holding its own copy.
  • An upper tier that aggregates fills across the entire network, consulted by every lower tier on a miss.

A request hits the lower tier first. A hit ends the request. A miss goes to the upper tier; a hit there fills the lower tier on the way back. Only when both tiers miss does the Worker execute, with the response then stored in both tiers.

BLOG-3262 image2

This topology means the first request from anywhere populates the upper tier. Every subsequent request — even from a data center that has never seen that URL — is served from the upper tier without the Worker running. The hit ratios are dramatically better than any single flat layer could deliver, which matters when your Worker is the origin. It is the same architecture behind zone-level Tiered Cache, minus the configuration dialog.

The stack composes with Smart Placement as well: tiers are checked first, and only a full miss triggers Smart Placement to route execution close to your origin. Some rough edges in that interaction are documented and being smoothed out.

Closing the distance gap

Web performance has always forced a compromise between two latencies: the round-trip to the user and the round-trip to your data. Cloudflare's network is within ~50ms of about 95% of Internet users, and Smart Placement plus Placement Hints keep code near backend data. But those two capabilities never fully composed — you could have one half of your app near the user and the other near the data, but orchestrating both required deep expertise.

Workers Cache is the missing seam. Because service bindings and ctx.exports calls between Workers traverse the cache, an application can be a chain of Workers, each deployed for the right reason:

BLOG-3262 image8
  • Worker A lives near the user and handles authentication, rate limiting, routing, header normalization, and rendering the HTML shell.
  • Worker B lives near the data, positioned via Smart Placement or a Placement Hint, and handles server-side rendering, catalog reads, search generation, and expensive transforms.
  • Workers Cache fronts Worker B. When Worker A calls B via a service binding, the cache is checked first. A hit returns the response without Worker B executing — no data-center hop, no database query, no rendering work.

The cache hit path collapses to user → Worker A → cached response. The data hop exists only on a miss, and even cold pages benefit from executing near the data when they do run.

No special architecture is required. Two Workers bound via a service binding, with caching enabled in Worker B's wrangler.jsonc, is all it takes.

BLOG-3262 image7

Multi-tenant isolation built into the cache key

Caching auth'd APIs has traditionally meant a brutal tradeoff: bypass the cache entirely or risk user A seeing user B's data. The standard bypass for Authorization headers protects privacy but forfeits the entire performance win.

Workers Cache resolves this by making the caller's ctx.props part of the cache key. When Worker A calls Worker B over a service binding and passes a user ID or tenant ID via ctx.props, the cache stores them separately. Different props, different entries.

import { WorkerEntrypoint } from "cloudflare:workers";

interface Props { userId: string; }

export default class Backend extends WorkerEntrypoint<Env, Props> {
  async fetch(request: Request): Promise<Response> {
    // ctx.props.userId is part of the cache key. User A and User B
    // requesting the same URL get separate cached entries.
    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=300",
      },
    });
  }
}

The typical pattern: a gateway Worker authenticates the request, strips the Authorization header, writes the authenticated user's ID into ctx.props, and then dispatches to the cached backend. The gateway runs on every request because it must, but the expensive backend only runs when that user's entry is missing. Auth'd APIs become cacheable per user with complete safety, and isolation comes free from the cache key itself.

Conventional CDNs force you to choose keying by user token or sending everything back to origin. Workers Cache offers a third path: shared, cached API responses with per-request authorization boundaries, as a native model for multi-tenant workloads.

A cache inside the call chain

The part of Workers Cache that changes how you build is also the easiest to miss if you still think of it as a CDN cache in front of a Worker. The cache actually sits in front of every Worker entrypoint: the default export, each named WorkerEntrypoint, and each call between entrypoints in the same Worker via ctx.exports. That last case is the differentiator.

When one entrypoint calls another through ctx.exports, the cache evaluates the call exactly as it would a request from a browser. A hit returns the stored response and the callee never executes. A miss runs the callee and stores its response under a cache key derived from the callee's entrypoint, path, query string, and ctx.props. The caller still runs on every request; only the handoff to the callee gets memoized independently.

You choose which entrypoints cache. In the Wrangler config, the exports map toggles caching per entrypoint by name ("default" refers to the default export). Opt an entrypoint in to cache its responses; opt one out to keep it running on every request. A router or gateway entrypoint — anything doing authentication, normalization, or dispatch — should be opted out so it always runs and its output is never served from cache.

That yields a composable primitive. You can write a Worker as a series of small entrypoints — auth, normalization, the expensive read, the data layer — and place a cache stage anywhere in the chain. Each cached entrypoint becomes a memoization unit with its own key, TTL, and tag namespace for purging. Every caching concern — when it runs, what it keys on, when it invalidates — is expressed as ordinary Worker code: which entrypoint you call, what request you forward, what ctx.props you pass, what Cache-Control you set.

As a concrete example, a single Worker can authenticate every request, cache an expensive backend behind a multi-tenant-aware cache key, and invalidate that cache when the underlying data changes. Caching is scoped per entrypoint: the gateway must execute on each request, so caching is disabled there and enabled only on the inner entrypoint, all in one small exports block.

{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-05-01",
  "cache": { "enabled": true },
  "exports": {
    // The gateway runs on every request — don't cache it.
    "default": { "type": "worker", "cache": { "enabled": false } },
    // Cache the expensive inner entrypoint.
    "CachedBackend": { "type": "worker", "cache": { "enabled": true } }
  }
}
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env { API_TOKEN: string; }
interface Props { userId: string; }

// Inner entrypoint: the expensive work. Workers Cache sits in front
// of this — on a hit, this code never runs.
export class CachedBackend extends WorkerEntrypoint<Env, Props> {
  async fetch(request: Request): Promise<Response> {
    // ctx.props.userId is part of the cache key, so this is cached
    // separately for every user.
    const { userId } = this.ctx.props;
    const data = await loadExpensiveData(userId);

    return new Response(JSON.stringify(data), {
      headers: {
        "Content-Type": "application/json",
        "Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
        "Cache-Tag": `user:${userId}`,
      },
    });
  }

  // Invalidate a user's cached response. purge() is scoped to the
  // entrypoint that calls it, so it must run inside CachedBackend —
  // the entrypoint that owns the cached response.
  async invalidate(userId: string): Promise<void> {
    await this.ctx.cache.purge({ tags: [`user:${userId}`] });
  }
}

// Outer entrypoint: runs on every request to authenticate and route.
// Caching is disabled for it in Wrangler config (above), so it always
// runs and the auth check is never skipped by a cache hit.
export default {
  async fetch(request, env, ctx): Promise<Response> {
    const userId = await authenticate(request, env);
    if (!userId) return new Response("Unauthorized", { status: 401 });

    // Invalidate this user's cache on writes, from the entrypoint that
    // owns it.
    if (request.method === "POST") {
      await handleWrite(request, userId);
      await ctx.exports.CachedBackend.invalidate(userId);
      return new Response("OK");
    }

    // For reads: strip Authorization (otherwise Cloudflare's automatic
    // bypass fires and nothing caches), then dispatch to the cached
    // backend with the authenticated user's identity in ctx.props.
    const forwarded = new Request(request);
    forwarded.headers.delete("Authorization");

    return ctx.exports.CachedBackend.fetch(forwarded, {
      props: { userId },
    });
  },
} satisfies ExportedHandler<Env>;

This is one Worker, one source file, one deploy, with two execution stages: the gateway runs uncached while the backend is cached. Between them sits a cache stage keyed per user, invalidated by the write path, and serving stale content during background refreshes. The cache isn't a bolt-on — it's a layer of the program expressed in code.

The same structure generalizes to several patterns:

  • Caching a Durable Object. Put the Durable Object behind an entrypoint, set Cache-Control on its responses, and reads stop reaching it on a hit. Writes go straight to the Durable Object and purge by tag; the object never knows caching exists.
  • Normalizing Accept-Encoding before Vary. An outer entrypoint restores the original encoding from request.cf.clientAcceptEncoding (Cloudflare's edge normalizes it for cache efficiency) before forwarding to a cached entrypoint that varies on the true value. Hit ratios stay high while clients receive the correct encoding.
  • Stripping tracking parameters. The outer entrypoint canonicalizes the URL, or sets a custom cache key with cf.cacheKey on the ctx.exports call, so cached inner entrypoints only see canonical URLs and ?utm_source=anything collapses into a single cache entry.

These layers stack. A single Worker can have an authenticating router on the outside, a normalizer that strips tracking parameters and restores headers, a cached entrypoint fronting a Durable Object, and a separate cached entrypoint for a public API — each connected by a cache stage you positioned rather than configured. The docs' Examples page walks through several of these end-to-end.

Built-in framework support

If you use Astro, the Cloudflare adapter wires Workers Cache up for you. Adding the cacheCloudflare provider to your config is all it takes:

// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import { cacheCloudflare } from "@astrojs/cloudflare/cache";

export default defineConfig({
  adapter: cloudflare(),
  output: "server",
  experimental: {
    cache: { provider: cacheCloudflare() },
    routeRules: {
      "/products/*": { maxAge: 300, swr: 3600, tags: ["products"] },
      "/blog/*":     { maxAge: 60,  swr: 86400, tags: ["blog"] },
    },
  },
});

The adapter enables the cache, sets the necessary response headers on generated pages, attaches Cache-Tag values for invalidation, and exposes a cache.invalidate() helper for tag-based purges. Astro pages that opt into server rendering pick up the "render once, cache, refresh in background" flow automatically — no per-route configuration or framework-specific runtime layer required. Work with the maintainers of other frameworks, including TanStack Start and Next.js via Vinext, is underway.

Observing cache behavior

Workers Observability now reports cache metadata per invocation:

BLOG-3262 image6

For each Worker you can inspect:

  • Cache hit ratio over time — the metric you want trending upward after enabling caching.
  • A breakdown of hits, misses, updates, and bypasses, which reveals why a low ratio exists: an excess of BYPASS (something setting a cookie?), too many MISS entries (the key partitioning more than expected?), or frequent UPDATING states (max-age shorter than the traffic interval?).

Because this sits in the same dashboard as logs, exceptions, CPU time, and request counts, you don't switch between zone-level and Worker-level views to diagnose caching behavior.

Pricing implications

Cache hits skip Worker execution entirely, so they don't bill CPU time. They do count as requests at the standard Workers request rate, like any other invocation. Misses and bypasses bill normally — request plus CPU time, exactly as without caching. There is no separate Workers Cache SKU and no per-gigabyte cache storage fee; tiered caching, purges, stale-while-revalidate, and the analytics above are all bundled. A cache-hit request therefore costs less than rendering the same response in the Worker.

One billing caveat: with caching enabled, requests that are normally free — static asset requests and worker-to-worker invocations through service bindings or ctx.exports — now incur the standard request rate, because each one consults a cache in front of the Worker.

What's next

  • Coordinated Smart Placement. Today the upper-tier cache location and Smart Placement target are chosen independently. On a full miss, a request can cross Cloudflare locations twice — once checking the upper tier, then again running near the data. Work is underway so a miss only makes that long-distance trip once.
  • Higher response size limits. At launch, all responses follow the Free plan's 512 MB cacheable size limit regardless of account. Standard per-plan limits will apply after rollout steps are completed.
  • Wider framework integration. Astro has built-in support; other frameworks are being added, including TanStack Start and Next.js through Vinext.
  • Stale-marking API. ctx.cache.purge() removes matching responses outright. An upcoming ctx.cache.invalidate() would mark them expired instead, so the next request can get a fast stale response via stale-while-revalidate while the Worker refreshes in the background.

Getting started

Workers Cache is live for every Worker on any plan. Enable it by adding "cache": { "enabled": true } to your wrangler.jsonc, redeploy, and start setting Cache-Control headers. The documentation covers the full surface: quickstart, cache keys, purging, composition patterns, and debugging.

Workers used to run in front of the cache. Now they can sit behind it as well — and with service bindings, on both sides of it at once.