Cloudflare Workers Cache

An edge cache in front of your worker - setup, cache policy, poisoning guard and purge

A practical guide for Next.js on OpenNext and for any other worker: how the cache in front of the worker works, what it costs, which headers to send, how to keep HTML and RSC apart and how to purge without a deploy. Every command and file can be copied with one click.

Vladimír NeporAuthorVladimír Nepor
Published
How Workers Cache works: a request first reaches the cache in front of the worker. A HIT is returned in about 50 ms without running the worker, only a MISS runs worker-entry.mjs and the Next.js render.RequestGET /blog/postGET /…?_rsc=x1y2zkey = path + queryWorkers Cachelower tier · coloupper tier · globalHIT ≈ 50 msworker does not runworker-entry.mjsNext.js renderMISS ≈ 0.4 s+ cache headers02 · SETUPEnable Workers Cachewrangler.jsonc + worker-entry.mjschapter →03 · CACHE POLICYEdge × browser TTL7 days at the edge, 10 min in browserchapter →04 · CACHE KEYCache poisoning guardkeep HTML and RSC apartchapter →05 · PURGEPurge without a deployctx.cache.purge() behind a tokenchapter →
Guide contents

00 · Overview

Workers Cache at a glance

Workers Cache is a cache that sits in front of your Cloudflare Worker. On a HIT, Cloudflare returns the stored response and your code does not run at all: no CPU time, no cold start, no database reads. You turn it on with one line in wrangler.jsonc, and the response headers your worker sends decide what gets stored and for how long.

The whole setup in short

  1. Enable it - "cache": { "enabled": true } and "workers_dev": false in wrangler.jsonc
  2. Set the policy in headers - a small worker-entry.mjs marks rendered pages as cacheable and everything else as no-store
  3. Guard the cache key - store only the response a normal visitor expects under that URL (never an RSC payload under a page URL)
  4. Purge and verify - a token-protected POST /__cache-purge endpoint and a few curl checks of Cf-Cache-Status

What are you trying to do?

QuestionWhere to find the answer
Is Workers Cache worth it for my project? What does it cost?How it works →
I want to turn it on for a Next.js (OpenNext) worker.Setup →
How long should pages stay at the edge and in the browser?Cache policy →
Visitors got raw RSC data instead of a page.Cache key and poisoning →
I changed content and the site still shows the old version.Purge →
What happens to the cache after a deploy or rollback?Deploy and rollback →
How do I check that it works?Verify →
We cache with caches.default in the worker today.From the Cache API →
What this guide covers

Workers Cache as launched by Cloudflare in July 2026, Wrangler 4.69 or newer, state as of October 2026. The examples use Next.js on OpenNext, because that is where the pitfalls are (RSC payloads, s-maxage). For any other worker, skip the RSC part: the rest applies as it is. The setup runs in production on our own websites.

01 · Basics

How Workers Cache works and what it costs

Without Workers Cache, every request starts your worker. Even a cache you build yourself with the Cache API (caches.default) only helps after the worker has started. Workers Cache answers before the worker, from a cache shared by the whole Cloudflare network.

Two tiers and request collapsing

  • Lower tier in the data center (colo) near the visitor, upper tier shared by the network. The first render anywhere fills the upper tier, so other data centers get a HIT without rendering again.
  • Request collapsing. When many requests for the same uncached URL arrive at once, Cloudflare runs the worker once per data center and serves the result to all of them.
  • stale-while-revalidate. After a page expires, the next visitor still gets the stored copy immediately and the worker renders a fresh one in the background. Nobody waits for a render.
  • Measured on a production Next.js site: HIT about 0.05 s, MISS about 0.4 s.

What you pay for

RequestBilledNote
HITrequest onlythe worker does not run, no CPU time
MISS, BYPASSrequest + CPUas without the cache
Static assets (JS, CSS, images)requestnormally free; with the cache enabled every request to the worker is billed, including /_next/static
Worker-to-worker callsrequestservice bindings and ctx.exports are billed too
Purge, storagenothing extrano separate Workers Cache price
Watch the static asset requests

A page that loads 20 assets from the same worker becomes 21 billed requests (on the Paid plan $0.30 per million above the included amount; on the Free plan they count against the daily request limit). Usually the CPU saved on renders pays for it many times over, but check your billing in the first weeks. Heavy media and downloads are better served from a public R2 bucket or a separate worker without the cache.

Pros and cons

Pros

  • Higher hit rate worldwide: one render fills the cache for all data centers.
  • A HIT runs no code: no CPU, no cold start, fewer D1 and R2 reads.
  • stale-while-revalidate and stale-if-error: no waiting after expiry, a stored copy when the render fails.
  • Purge by tag or path from the worker, without a deploy.
  • Less custom code than a hand-made Cache API layer.

Cons

  • Static asset requests become billed requests.
  • The cache key has no host, no cookies and no request headers. You have to guard it yourself.
  • No worker logs on a HIT. The cache status is only in the Cf-Cache-Status header and Workers Observability.
  • No pre-warming: every deploy starts with an empty cache.
  • OpenNext has no official integration yet, so a small wrapper sets the headers.

When it does not fit

  • Pages differ per logged-in user and you cannot separate them by path (cookies are not part of the key).
  • Most traffic is static files from the same worker and you are close to the Free plan request limit.
  • Content must change within seconds and you have no way to call a purge after each change.

02 · Setup

Enable Workers Cache step by step

Five steps for an existing Next.js app on OpenNext. The wrapper worker-entry.mjs sits between Cloudflare and the OpenNext worker. It only touches response headers, so it does not change how your app renders.

Step 1: Update Wrangler

Workers Cache needs Wrangler 4.69.0 or newer (4.107.0 for per-entrypoint settings and cross_version_cache).

Terminal
npm install -D wrangler@latest
npx wrangler --version   # 4.69.0 or newer

Step 2: Enable the cache in wrangler.jsonc

Point main to the wrapper and turn the cache on. Keep the rest of your config (bindings, D1, R2) as it is.

wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-app",
  // instead of ".open-next/worker.js": the wrapper sets the cache headers
  "main": "./worker-entry.mjs",
  "compatibility_date": "2026-07-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "directory": ".open-next/assets", "binding": "ASSETS" },

  // Workers Cache: Cloudflare answers from the cache before the worker runs
  "cache": { "enabled": true },

  // The cache key has no host: *.workers.dev would share (and fill) the same cache
  "workers_dev": false
}

workers_dev: false matters: the cache key does not contain the host, so a *.workers.dev URL would serve and fill the same cache, and a redirect inside the worker would never run on a HIT.

Step 3: Add worker-entry.mjs

Create the file in the project root and set SITE_HOST to your production host. Chapters Cache policy and Cache key explain each part.

worker-entry.mjs
// worker-entry.mjs - cache policy for Workers Cache in front of an OpenNext (Next.js) worker.
// Workers Cache answers BEFORE this code runs. This file only decides, through response
// headers, what the edge may store. Anything not explicitly allowed gets no-store.
import openNextWorker from "./.open-next/worker.js";
// export { DOQueueHandler } from "./.open-next/worker.js"; // only if you use the DO queue

const SITE_HOST = "www.example.com"; // the only host whose pages may be stored
const NEVER_CACHE = ["/api/"];        // path prefixes that are never stored

// Edge: 7 days fresh, then 1 more day stale-while-revalidate / stale-if-error.
// Use max-age, not s-maxage: s-maxage and must-revalidate switch stale-while-revalidate off.
const EDGE_PAGE_CC = "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400";
// Browser: 10 minutes. The edge copy can be purged at any time, the browser copy cannot.
const CLIENT_PAGE_CC = "public, max-age=600";
const NO_STORE = "no-store";

// Edge-only header: highest precedence, stripped before the response reaches the browser.
const EDGE_CC = "Cloudflare-CDN-Cache-Control";

// Headers the Next.js router sends with RSC requests (client-side navigation, prefetch).
const ROUTER_HEADERS = ["rsc", "next-router-state-tree", "next-router-prefetch", "next-router-segment-prefetch", "next-url"];

/* ---------- canonical variant guard ----------
 * The cache key is path + query only. If an RSC payload is ever stored under a page URL,
 * every visitor gets raw RSC instead of HTML until the entry expires. So only the response
 * a normal visitor expects under that URL may be stored:
 *   HTML: no _rsc parameter and no router headers,
 *   RSC:  "RSC: 1" and _rsc equal to the hash Next.js derives from the router headers
 *         (next/dist/shared/lib/router/utils/cache-busting-search-param.js).
 * If Next.js ever changes the formula, RSC simply stops being cached (fail-safe).
 */
function djb2(str) {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) hash = ((hash << 5) + hash + str.charCodeAt(i)) & 0xffffffff;
  return hash >>> 0;
}

function expectedRscParam(req) {
  const h = req.headers;
  const p = h.get("next-router-prefetch");
  const prefetch = p === "1" || p === "2" ? p : undefined;
  const segment = h.get("next-router-segment-prefetch") || undefined;
  const tree = h.get("next-router-state-tree") ?? undefined;
  const nextUrl = h.get("next-url") ?? undefined;
  if ([prefetch, segment, tree, nextUrl].every((v) => v === undefined)) return "";
  return djb2([prefetch || "0", segment || "0", tree || "0", nextUrl || "0"].join(",")).toString(36).slice(0, 5);
}

function isCanonicalPage(req, url, type) {
  const rsc = url.searchParams.get("_rsc"); // null = parameter missing
  if (type.startsWith("text/html")) {
    return rsc === null && !ROUTER_HEADERS.some((name) => req.headers.has(name));
  }
  if (type.startsWith("text/x-component")) {
    return req.headers.get("rsc") === "1" && rsc !== null && rsc === expectedRscParam(req);
  }
  return false;
}

/* ---------- request passed to Next.js on a MISS ----------
 * The cache key ignores cookies and Authorization, so always render the anonymous variant.
 * Conditional headers are dropped so the origin returns a full 200 for the edge to store
 * (the edge answers If-None-Match with 304 by itself). Router headers stay: Next.js needs
 * them to build the right RSC payload.
 */
function upstreamRequest(request, url) {
  if (request.method !== "GET" || NEVER_CACHE.some((p) => url.pathname.startsWith(p))) return request;
  const h = new Headers(request.headers);
  for (const name of ["Cookie", "Authorization", "If-None-Match", "If-Modified-Since"]) h.delete(name);
  return new Request(request, { headers: h });
}

/* ---------- response headers = cache policy ---------- */

function noStore(h) {
  h.set(EDGE_CC, NO_STORE);
  h.set("Cache-Control", NO_STORE); // also stops other shared caches from keeping Next's s-maxage
}

// Next.js on Workers returns uncompressed bodies and Cloudflare compresses at the edge,
// so Vary: Accept-Encoding would only split the cache into identical variants.
function dropAcceptEncodingVary(h) {
  if (h.has("Content-Encoding")) return;
  const vary = (h.get("Vary") || "")
    .split(",")
    .map((v) => v.trim())
    .filter((v) => v && v.toLowerCase() !== "accept-encoding");
  if (vary.length) h.set("Vary", vary.join(", "));
  else h.delete("Vary");
}

function applyCachePolicy(request, url, resp) {
  const out = new Response(resp.body, resp); // mutable copy
  const h = out.headers;
  const type = (h.get("Content-Type") || "").toLowerCase();
  const isPage = type.startsWith("text/html") || type.startsWith("text/x-component");
  const cacheableRequest =
    request.method === "GET" &&
    url.hostname === SITE_HOST &&
    !NEVER_CACHE.some((p) => url.pathname.startsWith(p));

  if (isPage) {
    const originCc = (h.get("Cache-Control") || "").toLowerCase();
    const store =
      cacheableRequest &&
      out.status === 200 &&
      !h.has("Set-Cookie") &&
      !originCc.includes("private") &&
      !originCc.includes("no-store") &&
      isCanonicalPage(request, url, type);

    if (!store) {
      noStore(h);
      return out;
    }
    h.set(EDGE_CC, EDGE_PAGE_CC);
    h.set("Cache-Control", CLIENT_PAGE_CC);
    h.set("Cache-Tag", type.startsWith("text/html") ? "html" : "rsc"); // for targeted purge, not sent to the browser
    dropAcceptEncodingVary(h);
    return out;
  }

  // Everything else (API, sitemaps, redirects, errors). Without an explicit Cache-Control,
  // Workers Cache applies heuristic TTLs (200 for 2 h, 301 for 20 min, 404 for 3 min).
  if (!cacheableRequest || out.status >= 400 || !h.has("Cache-Control")) noStore(h);
  return out;
}

/* ---------- POST /__cache-purge (token in CACHE_PURGE_TOKEN) ---------- */

async function sha256(text) {
  return new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
}

async function tokenMatches(request, expected) {
  const auth = request.headers.get("Authorization") || "";
  const given = auth.startsWith("Bearer ") ? auth.slice(7) : "";
  // Hash both sides first: equal lengths, so the comparison runs in constant time.
  const [a, b] = await Promise.all([sha256(given), sha256(expected)]);
  return crypto.subtle.timingSafeEqual(a, b);
}

async function handlePurge(request, env, ctx) {
  const headers = { "Cache-Control": NO_STORE, [EDGE_CC]: NO_STORE };
  if (!env.CACHE_PURGE_TOKEN || request.method !== "POST") return new Response("Not Found", { status: 404, headers });
  if (!(await tokenMatches(request, env.CACHE_PURGE_TOKEN))) return new Response("Unauthorized", { status: 401, headers });

  const body = await request.json().catch(() => ({}));
  const scope = {};
  if (Array.isArray(body.tags) && body.tags.length) scope.tags = body.tags;
  if (Array.isArray(body.pathPrefixes) && body.pathPrefixes.length) scope.pathPrefixes = body.pathPrefixes;

  const result = await ctx.cache.purge(Object.keys(scope).length ? scope : { purgeEverything: true });
  return Response.json(result, { status: result.success ? 200 : 500, headers });
}

/* ---------- entry ---------- */

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname === "/__cache-purge") return handlePurge(request, env, ctx);

    const resp = await openNextWorker.fetch(upstreamRequest(request, url), env, ctx);
    return applyCachePolicy(request, url, resp);
  },
};

Do you use cookies for logged-in users? Then do not strip Cookie for those paths: add them to NEVER_CACHE.

Step 4: Create the purge token

Without the CACHE_PURGE_TOKEN secret the purge endpoint does not exist (it returns 404). Run in the project folder (Linux, macOS or WSL):

Terminal
# generate a random token into .env (.env must be in .gitignore)
echo "CACHE_PURGE_TOKEN=$(openssl rand -hex 32)" >> .env

# store the same value as a worker secret
grep '^CACHE_PURGE_TOKEN=' .env | cut -d= -f2 | npx wrangler secret put CACHE_PURGE_TOKEN

Step 5: Build and deploy

Terminal
npx opennextjs-cloudflare build
npx opennextjs-cloudflare deploy

Then run the checks in Verify. The first request to a page is a MISS, the second a HIT.

Redirect apex → www before the cache

A redirect written in the worker (for example example.com → www.example.com) is skipped on a HIT, because the host is not part of the key. Use a zone Redirect Rule in the Cloudflare dashboard instead. It runs before Workers Cache.

03 · Cache policy

What to cache and for how long

Cache Rules, Page Rules and Cache Reserve from the zone settings do not apply to Workers Cache. What is stored and for how long is decided only by the headers of the response your worker returns.

Recommended rules

Path / responseEdge (Cloudflare)Browser
HTML page, canonical request7 days + 1 day SWR + 1 day stale-if-error10 min
RSC payload from the router, canonical request7 days + 1 day SWR + 1 day stale-if-error10 min
HTML or RSC that is not canonicalnot storedno-store
404 and other non-200 pagesnot storedno-store
/api/*not storedno-store
Other responses (sitemaps, redirects)by their own Cache-Control, without one not storedby Next.js
A host other than the production onenot stored-
Any method other than GETnot stored-

Two headers: edge and browser

Cloudflare reads cache directives from three headers, in this order of precedence: Cloudflare-CDN-Cache-Control, CDN-Cache-Control and Cache-Control. The first one is removed before the response reaches the visitor. That is how the edge can keep a page for 7 days while the browser keeps it for 10 minutes.

HTTP
Cloudflare-CDN-Cache-Control: max-age=604800, stale-while-revalidate=86400, stale-if-error=86400
Cache-Control: public, max-age=600
Cache-Tag: html
  • Cloudflare-CDN-Cache-Control - for the edge only, highest priority, the visitor never sees it.
  • Cache-Control - goes to the visitor.
  • Cache-Tag - labels for a targeted purge (html, rsc), also hidden from the visitor.

Life of one page at the edge

Life of one cached page: fresh for 7 days, then for 1 more day served stale while it refreshes in the background (or when the render fails), after 8 days it is dropped. The browser keeps its copy for 10 minutes only.Edge (Workers Cache)Fresh: served from cache, the worker does not rundropped07 days8 daysSWR + stale-if-errorstale copy returned at once,re-render in the backgroundnext visitwaits for renderBrowser10 min, then a 304 from the edge (≈ 50 ms, no worker)
7 days is an upper limit, not a guarantee. Cloudflare may evict pages with little traffic earlier.

With the sample values, every page at the edge goes through three phases:

  • 0-7 days - fresh. Served from the cache, the worker does not run.
  • 7-8 days - stale but usable. SWR: the first visitor gets the old copy at once and the page is rendered again in the background. stale-if-error: when the render fails (database down, 5xx), the stored copy is returned.
  • After 8 days - dropped. The next visit waits for a render.

Why only 10 minutes in the browser

You can drop the edge copy at any time (deploy, rollback, purge). The browser copy is out of your reach. If a broken page gets out, it stays with a visitor for at most 10 minutes.

On a HIT Cloudflare also sends Age (how long the copy has been in the cache), and the browser subtracts it from max-age. A copy older than 10 minutes is therefore revalidated on the next visit and answered with 304 Not Modified straight from the edge (about 50 ms, no worker). That is cheap and expected.

Header pitfalls

PitfallWhat happensWhat to do
No Cache-ControlStored by heuristics anyway: 200 for 2 hours, 301 for 20 minutes, 404 for 3 minutes.Everything that must not be cached gets an explicit no-store.
s-maxage, must-revalidate, proxy-revalidateTurn stale-while-revalidate off. An expired page waits for a render.Next.js sends s-maxage on static pages. Set the edge policy with max-age in Cloudflare-CDN-Cache-Control.
Set-Cookie in the responseAutomatic BYPASS, the worker runs every time.Do not set cookies on public pages (consent banners, A/B tests on the client).
Authorization in the requestBYPASS, unless the response is public. Then it is stored and shared.Strip Authorization before the render (the sample does), so the stored copy is always anonymous.
Vary: Accept-EncodingSplits the cache into identical variants per browser.Next.js on Workers returns uncompressed bodies and the edge compresses them, so the wrapper drops accept-encoding from Vary.
Key takeaways
  • Long TTL at the edge, short in the browser: the edge can be purged, the browser cannot.
  • Edge policy in Cloudflare-CDN-Cache-Control with max-age, never s-maxage.
  • No header does not mean no cache. Mark dynamic responses no-store.

04 · Cache key

Cache key and cache poisoning

This is the most important chapter for security. The cache key is much simpler than with a classic CDN. If two different responses share one URL, the cache can mix them up, and one ordinary request is enough to break a page for everybody.

What is in the key

In the keyNot in the key
path + query string (order of parameters matters)host
worker version: every deploy, rollback and wrangler secret put starts with an empty cachecookies
the target entrypoint of the workerAuthorization and other request headers
method: GET and HEAD share one entry
Do not rely on Vary

The documentation says Workers Cache honours all Vary header names. In practice, an RSC payload was still stored under a page URL even though Next.js sends Vary: rsc, next-router-state-tree, …. Make the URL alone identify the variant.

What happened: RSC under a page URL

Cache poisoning: one request with the RSC: 1 header to a plain page URL. Without a guard the RSC payload is stored under the HTML key and every visitor gets raw data. With the guard the response is not stored and the HTML stays intact.WITHOUT GUARDGET /pricing + RSC: 1→ text/x-componentstored under /pricingvisitors get raw RSCWITH GUARDGET /pricing + RSC: 1→ text/x-componentnot canonical → no-storeHTML stays intact
Anyone can send this request, by mistake or on purpose. That is why the guard belongs in every setup, not only in debugging.

While debugging, a request with the RSC: 1 header went to a plain page URL in production, without ?_rsc=. Next.js correctly returned an RSC payload (text/x-component). Workers Cache stored it under the page URL, and for the whole TTL visitors got raw payload text instead of the page.

One ordinary request with one header was enough. The cache cannot tell the two responses apart, because both have the same URL and therefore the same key.

The guard: store only the canonical response

Store a response only when it is exactly what a normal visitor expects under that URL (isCanonicalPage in the sample):

  • HTML only for a request without _rsc and without any router header (rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, next-url).
  • RSC only with RSC: 1 and an _rsc value that exactly matches the hash of the router headers. Next.js computes it as djb2 of prefetch,segmentPrefetch,stateTree,nextUrl, base 36, first 5 characters. Next.js has this check itself (experimental.validateRSCRequestHeaders), but it does not run under OpenNext, so the wrapper does it.
  • And only: the production host, GET, status 200, no Set-Cookie, no private or no-store from the origin.

Everything else is still served to the visitor, just with no-store in both headers. The second one (Cache-Control) stops other shared caches on the way from keeping the Next.js s-maxage.

Result: the request from the incident is served as BYPASS and the HTML under the same URL stays intact. A forged _rsc hash is not stored either.

Test on your own URLs

Run tests that could poison the cache on a URL with a unique parameter (?cachetest=<random number>). It has its own key and real pages are not affected.

05 · Purge

Purge without a deploy

Purge in the Cloudflare dashboard (and via the zone API or Terraform) does not reach Workers Cache. You can only purge from the worker itself with ctx.cache.purge(), and only the cache of that worker. The sample exposes it as a token-protected endpoint.

Endpoint POST /__cache-purge

Terminal
export CACHE_PURGE_TOKEN=$(grep '^CACHE_PURGE_TOKEN=' .env | cut -d= -f2)

# everything
curl -X POST https://www.example.com/__cache-purge \
  -H "Authorization: Bearer $CACHE_PURGE_TOKEN"

# by path prefix (the page and everything below it)
curl -X POST https://www.example.com/__cache-purge \
  -H "Authorization: Bearer $CACHE_PURGE_TOKEN" \
  -d '{"pathPrefixes":["/blog"]}'

# by tag: only RSC payloads (html = documents)
curl -X POST https://www.example.com/__cache-purge \
  -H "Authorization: Bearer $CACHE_PURGE_TOKEN" \
  -d '{"tags":["rsc"]}'
  • No body purges everything, pathPrefixes purges a page and everything below it, tags purges by Cache-Tag.
  • Without a token or with a wrong token: 401. GET: 404. Without the secret the endpoint does not exist (404).
  • The token is compared in constant time (both sides hashed with SHA-256, then timingSafeEqual).
  • Purge is rate limited with the Free plan limits of zone purge on every plan. Call it once after a batch of changes, not after each one.

Content changes

Changes in the database (new articles, edits, ordering) show up on their own only when the edge copy expires, which means up to 7 days (8 at worst). After a batch of changes, call a purge: everything, or just the affected pathPrefixes. The best place is the save action of your CMS or admin.

Other ways to empty the cache

  • Deploy or rollback: a new version means a new key.
  • wrangler secret put also creates a new version, so the cache starts empty as well.
What a leaked token can do

Only empty the cache. It cannot read data or change content. Repeated purges would keep the cache empty: more CPU, a higher bill, a slower site, or an exhausted purge limit. The fix is to rotate the token. Delete the old line from .env first, then run step 4 of Setup again.

06 · Deploy

Deploy, rollback and old chunks

The cache settings are part of the worker version. A deploy is effective immediately and never serves HTML that points to old chunks, but the hit rate starts from zero each time.

Wrapper changes need no rebuild

The OpenNext build only produces .open-next/. Wrangler bundles worker-entry.mjs at deploy time. Changes in the wrapper or in wrangler.jsonc therefore need only a deploy. Rebuild only after changes in src/, next.config.ts and similar.

Terminal
# only worker-entry.mjs or wrangler.jsonc changed: no rebuild needed
npx opennextjs-cloudflare deploy

Rollback

Terminal
npx wrangler rollback   # previous version, including its cache settings
  • A rollback also restores the cache settings of that version.
  • "enabled": false does not switch the cache off retroactively: stored entries are not deleted and are used again if the cache is turned back on.

Keep cross_version_cache off

cross_version_cache: true keeps the warm cache across deploys. Old HTML would then point to _next/static files the new build may no longer have, and the page breaks with a ChunkLoadError. If you turn it on anyway, purge after every deploy that changes content.

Keep the previous builds in .open-next/assets/_next/static either way. Browsers keep HTML for up to 10 minutes and open tabs load chunks of their own version. See merging old _next/static chunks.

07 · Verify

How to check that it works

A HIT creates no worker logs, so the response header Cf-Cache-Status is the main tool. Hit ratio and HIT / MISS / BYPASS counts are also in Workers Observability.

Checks after a deploy

Terminal
# GET twice (not curl -I: only GET responses are stored)
curl -s -o /dev/null -D - https://www.example.com/ | grep -i -E "cf-cache-status|^cache-control"
curl -s -o /dev/null -D - https://www.example.com/ | grep -i -E "cf-cache-status|^cache-control"
Terminal
# a unique URL of its own, so a failed test cannot poison a real page
T="https://www.example.com/?cachetest=$RANDOM"
curl -s -o /dev/null -D - -H "RSC: 1" "$T" | grep -i -E "cf-cache-status|content-type"   # BYPASS, text/x-component
curl -s -o /dev/null -D - "$T" | grep -i -E "cf-cache-status|content-type"               # text/html
CheckHowExpected
The page is cachedGET the same URL twiceMISS, then HIT; Cache-Control: public, max-age=600
Poisoning guardRSC: 1 on a plain URL, then the same URL without itfirst BYPASS + text/x-component, then text/html
API is not cachedany /api/… URLBYPASS, Cache-Control: no-store
404 is not cacheda URL that does not existBYPASS, no-store
RevalidationIf-None-Match: "<etag>" on a cached page304, HIT
Internal headers do not leakheaders of any pageno Cloudflare-CDN-Cache-Control, no Cache-Tag
Client navigationclick through the site with DevTools openno errors in the console

Values of Cf-Cache-Status

ValueMeaning
HITServed from the cache, the worker did not run.
MISSNot in the cache: the worker ran and the response was stored (if allowed).
EXPIREDThe copy expired and was rendered again.
REVALIDATEDThe expired copy was confirmed as still valid.
UPDATINGstale-while-revalidate: the old copy was served, a new one is being rendered in the background.
STALEThe old copy was served because the render failed (stale-if-error).
BYPASSNot stored: no-store, Set-Cookie, Authorization or a method other than GET.

08 · Migration

From a custom Cache API layer to Workers Cache

Before Workers Cache, the usual way to cache rendered pages was a wrapper that stored HTML and RSC in caches.default (Cache API). If you run that today, switching is mostly deleting code.

Cache API (caches.default in the wrapper)Workers Cache
Where the cache isinside the worker, which runs on every requestin front of the worker, which does not run on a HIT
Reachone data center, each fills separatelylower tier + upper tier shared by the network
Keyyour own (hash of _rsc, router headers, deploy id)path + query + worker version
After expirythe first visitor waits for a renderSWR: old copy now, refresh in the background
Purgenot possible across data centers, wait for the TTLctx.cache.purge()
After a deploycold cache (deploy id in the key)cold cache (version in the key)
Billed on a hitrequest + CPUrequest only

Migration in four steps

  1. Remove the caches.default lookups, custom keys, hashing and Vary rewriting from worker-entry.mjs.
  2. Enable the cache in wrangler.jsonc (step 2).
  3. Set the cache policy in response headers, including the variant guard (step 3).
  4. Check with Cf-Cache-Status instead of your own header such as X-Edge-Cache (Verify).
The Cache API still has its place

For caching data inside the worker, for example a colo-local layer in front of an R2 bucket, the Cache API remains the right tool. See R2 storage and cache.

Sources

Official documentation and further reading

Need a faster site on Cloudflare?

We set up Workers Cache, OpenNext and the whole Cloudflare stack for your project.

Contact Us