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 →
One worker serves several domains and the HTML differs per domain.Multiple domains →
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 (cache analytics is still coming).
  • 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.
  • One worker serves several domains and the HTML differs per domain (canonical, robots). Turning the cache on is not enough there, see Multiple domains.

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 GET (HEAD shares the entry with GET)not 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

No host in the key means the cache belongs to the worker, not to a domain. Does one worker serve several domains with different HTML? Then read Multiple domains before you turn the cache on.

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. Cache analytics in Workers Observability is announced by Cloudflare as coming soon.

Checks after a deploy

Terminal
# GET twice (curl -I works too: a cold HEAD is turned into a GET by Cloudflare)
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 and HEAD.

08 · Multiple domains

One worker, several domains, HTML that depends on the host

A typical case is a white-label site or a game player: one worker, connected through routes or custom domains to www.example.com, www.example.net and more, returns HTML that contains values taken from the host: canonical and og:url with the full domain, robots that differ per domain, links to “more games” that point elsewhere. Here, "cache": { "enabled": true } on its own breaks the site.

Why turning the cache on is not enough

  1. The host is not in the key. The first request to www.example.net/game/foo/ stores HTML with a canonical on example.net. A visitor of www.example.com/game/foo/ then gets the same page, including the foreign canonical and robots. It looks fine, but it hurts SEO and can mix content between tenants. caches.default does not have this problem: its key is a Request with the full URL, domain included.
  2. A host check in the worker does not help. A wrapper that stores only one host (SITE_HOST in the sample) prevents storing the other domains, but a HIT stored by the main domain is returned to all of them before the worker runs.
  3. Responses without Cache-Control are cached by heuristics. With caches.default your code decides what to store. Workers Cache is opt-out: an absolute redirect (/game/foo → https://www.example.net/game/foo/) is stored for 20 minutes and sends visitors of one domain to another.
  4. On a HIT the worker does not run. Code that rewrote headers on a caches.default HIT (browser Cache-Control, a diagnostic X-Cache) never runs. The stored response must already be correct.

The safe setup: a gateway and ctx.props

Cloudflare recommends telling the tenants apart in a gateway and passing the host in ctx.props, which is part of the cache key. The default entrypoint (gateway) has the cache switched off and runs on every request. A named entrypoint has the cache on, and the gateway calls it through ctx.exports.

wrangler.jsonc
{
  "main": "./worker-entry.mjs",
  "workers_dev": false,            // *.workers.dev would share the same cache
  "cache": { "enabled": true },
  // per-entrypoint settings: Wrangler 4.107.0 or newer
  "exports": {
    "default":   { "type": "worker", "cache": { "enabled": false } },  // gateway, runs on every request
    "SiteCache": { "type": "worker", "cache": { "enabled": true } }    // cached entrypoint
  }
}
worker-entry.mjs
import { WorkerEntrypoint } from "cloudflare:workers";
// render(), isAsset() and isCacheablePath() stand for your own app code.

// Cached entrypoint: the response may depend only on the URL and on ctx.props.
export class SiteCache extends WorkerEntrypoint {
  async fetch(request) {
    const { host } = this.ctx.props;                 // part of the cache key
    const res = await render(request, this.env, host);

    // Store only 200. Errors and redirects (often absolute, with a domain) get no-store.
    if (res.status === 200) return res;
    const out = new Response(res.body, res);
    out.headers.set("Cache-Control", "no-store");
    return out;
  }
}

// Gateway: cache disabled, runs on every request (redirects belong here).
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const host = url.hostname.toLowerCase();

    // POST, API, anything personal: straight to the app, never through the cache
    if ((request.method !== "GET" && request.method !== "HEAD") || !isCacheablePath(url)) {
      return render(request, env, host);
    }

    // Host-dependent HTML: host in props = host in the cache key.
    // Host-independent files: the same props for every domain, higher hit rate.
    const props = isAsset(url) ? { kind: "asset" } : { kind: "page", host };
    return ctx.exports.SiteCache.fetch(request, { props });
  },
};
  • Switch the gateway off with exports, not with no-store on every response. That would add a needless round-trip to the tiered cache.
  • Redirects (trailing slash, old slugs) run on a HIT only if they are in the gateway, not in the cached entrypoint.
  • Set-Cookie in the response or Authorization in the request means an automatic BYPASS. If the gateway checks a token, it has to strip Authorization and pass the identity in props.
  • For HTML use the same headers as in Cache policy: max-age with stale-while-revalidate in Cloudflare-CDN-Cache-Control, never s-maxage or must-revalidate for the edge.

What it costs: two requests instead of one

With the cache on, calls between entrypoints through ctx.exports are billed too, although they are otherwise free. The gateway pattern therefore counts two billed requests for every page and every file: the gateway and the cached entrypoint.

caches.defaultWorkers Cache + gateway
Billed requests per page or file1×2×
CPU on a HITthe worker runs (lookup + header copy, typically about 1 ms)only the gateway, the cached entrypoint does not run
Reach of the cacheone data centerlower + upper tier across the network
Concurrent MISSeseach one renders or reads R2collapsed into one call per data center
Stale copy (SWR)noyes
Purgenot across data centersctx.cache.purge() by tag, prefix or everything
After a deploythe cache survives (unless you put a deploy id in the key)cold (version in the key), unless cross_version_cache

If most traffic is a HIT and a HIT from caches.default costs about 1 ms of CPU, the saved CPU does not pay for the second request. It pays off where a MISS is expensive (long render, many R2 reads, traffic spread over many data centers) or where you need purge and SWR.

How to decide

SituationRecommendation
One domain, or responses that do not depend on the hostTurning it on is enough: "cache": { "enabled": true }, workers_dev: false and an explicit no-store for errors (Setup).
Several domains, host-dependent HTML, expensive MISSesGateway with the host in ctx.props, count on 2× requests.
Several domains, host-dependent HTML, cheap HITs in caches.defaultStay with caches.default. Turning the cache on without a gateway breaks the canonical.
Pages that differ per user only by a cookieNot suitable, cookies are not part of the key.
From practice

A game player on several domains, HTML with a canonical by host, most traffic a HIT: we decided to stay with caches.default. A comment in wrangler.jsonc explains why Workers Cache must not be turned on without a gateway, so nobody switches it on later by mistake.

Checklist before switching

  • Go through every route that reads Host, cookies, User-Agent or Accept-Language. Whatever changes the response must go into props, or the response must not be cacheable.
  • Every response that must not be cached sends no-store or private. A missing header is not an opt-out.
  • workers_dev: false, so *.workers.dev does not fill the shared cache.
  • HEAD and GET share one entry. On a cold cache Cloudflare turns a HEAD into a GET, so a response without a body is never stored.
  • Range: Cloudflare strips the Range header, asks the worker for the full 200 and slices it itself. A 206 returned by the worker is never stored.
  • Pre-compressed files (.br, .gz with Content-Encoding set by the worker): Cloudflare stores the Content-Encoding the worker sets. Check Content-Encoding and Content-Length on a HIT and a MISS before going live (for example, Unity loaders compute progress from Content-Length).
  • After the deploy, request the same path on two different domains, check Cf-Cache-Status (MISS → HIT) and compare the canonical.
  • HITs do not show up in worker logs, and purge from the dashboard does not reach this cache.

09 · 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

When to stay with the Cache API

  • One worker, several domains, HTML that depends on the host, and most traffic is already a cheap HIT from caches.default. The safe Workers Cache setup doubles the billed requests, see the cost.
  • You need the cache to survive a deploy and cannot run with cross_version_cache (see Deploy).
  • Most requests are static files from the same worker and the extra billed requests would cost more than the saved CPU.

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