- Vrealmatic
- Cloudflare
- Workers Cache
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.
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
- Enable it -
"cache": { "enabled": true }and"workers_dev": falseinwrangler.jsonc - Set the policy in headers - a small
worker-entry.mjsmarks rendered pages as cacheable and everything else asno-store - Guard the cache key - store only the response a normal visitor expects under that URL (never an RSC payload under a page URL)
- Purge and verify - a token-protected
POST /__cache-purgeendpoint and a fewcurlchecks ofCf-Cache-Status
What are you trying to do?
| Question | Where 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 → |
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
| Request | Billed | Note |
|---|---|---|
HIT | request only | the worker does not run, no CPU time |
MISS, BYPASS | request + CPU | as without the cache |
| Static assets (JS, CSS, images) | request | normally free; with the cache enabled every request to the worker is billed, including /_next/static |
| Worker-to-worker calls | request | service bindings and ctx.exports are billed too |
| Purge, storage | nothing extra | no separate Workers Cache price |
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).
npm install -D wrangler@latest
npx wrangler --version # 4.69.0 or newerStep 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.
{
"$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 - 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):
# 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_TOKENStep 5: Build and deploy
npx opennextjs-cloudflare build
npx opennextjs-cloudflare deployThen run the checks in Verify. The first request to a page is a MISS, the second a HIT.
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 / response | Edge (Cloudflare) | Browser |
|---|---|---|
| HTML page, canonical request | 7 days + 1 day SWR + 1 day stale-if-error | 10 min |
| RSC payload from the router, canonical request | 7 days + 1 day SWR + 1 day stale-if-error | 10 min |
| HTML or RSC that is not canonical | not stored | no-store |
| 404 and other non-200 pages | not stored | no-store |
/api/* | not stored | no-store |
| Other responses (sitemaps, redirects) | by their own Cache-Control, without one not stored | by Next.js |
| A host other than the production one | not 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.
Cloudflare-CDN-Cache-Control: max-age=604800, stale-while-revalidate=86400, stale-if-error=86400
Cache-Control: public, max-age=600
Cache-Tag: htmlCloudflare-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
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
| Pitfall | What happens | What to do |
|---|---|---|
No Cache-Control | Stored 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-revalidate | Turn 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 response | Automatic BYPASS, the worker runs every time. | Do not set cookies on public pages (consent banners, A/B tests on the client). |
Authorization in the request | BYPASS, 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-Encoding | Splits 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. |
- Long TTL at the edge, short in the browser: the edge can be purged, the browser cannot.
- Edge policy in
Cloudflare-CDN-Cache-Controlwithmax-age, nevers-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 key | Not in the key |
|---|---|
| path + query string (order of parameters matters) | host |
worker version: every deploy, rollback and wrangler secret put starts with an empty cache | cookies |
| the target entrypoint of the worker | Authorization 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.
VaryThe 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
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
_rscand without any router header (rsc,next-router-state-tree,next-router-prefetch,next-router-segment-prefetch,next-url). - RSC only with
RSC: 1and an_rscvalue that exactly matches the hash of the router headers. Next.js computes it as djb2 ofprefetch,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, noSet-Cookie, noprivateorno-storefrom 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.
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
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,
pathPrefixespurges a page and everything below it,tagspurges byCache-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 putalso creates a new version, so the cache starts empty as well.
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.
# only worker-entry.mjs or wrangler.jsonc changed: no rebuild needed
npx opennextjs-cloudflare deployRollback
npx wrangler rollback # previous version, including its cache settings- A rollback also restores the
cachesettings of that version. "enabled": falsedoes 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
# 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"# 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| Check | How | Expected |
|---|---|---|
| The page is cached | GET the same URL twice | MISS, then HIT; Cache-Control: public, max-age=600 |
| Poisoning guard | RSC: 1 on a plain URL, then the same URL without it | first BYPASS + text/x-component, then text/html |
| API is not cached | any /api/… URL | BYPASS, Cache-Control: no-store |
| 404 is not cached | a URL that does not exist | BYPASS, no-store |
| Revalidation | If-None-Match: "<etag>" on a cached page | 304, HIT |
| Internal headers do not leak | headers of any page | no Cloudflare-CDN-Cache-Control, no Cache-Tag |
| Client navigation | click through the site with DevTools open | no errors in the console |
Values of Cf-Cache-Status
| Value | Meaning |
|---|---|
HIT | Served from the cache, the worker did not run. |
MISS | Not in the cache: the worker ran and the response was stored (if allowed). |
EXPIRED | The copy expired and was rendered again. |
REVALIDATED | The expired copy was confirmed as still valid. |
UPDATING | stale-while-revalidate: the old copy was served, a new one is being rendered in the background. |
STALE | The old copy was served because the render failed (stale-if-error). |
BYPASS | Not 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
- The host is not in the key. The first request to
www.example.net/game/foo/stores HTML with a canonical onexample.net. A visitor ofwww.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.defaultdoes not have this problem: its key is aRequestwith the full URL, domain included. - A host check in the worker does not help. A wrapper that stores only one host (
SITE_HOSTin 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. - Responses without
Cache-Controlare cached by heuristics. Withcaches.defaultyour 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. - On a HIT the worker does not run. Code that rewrote headers on a
caches.defaultHIT (browserCache-Control, a diagnosticX-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.
{
"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
}
}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 withno-storeon 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-Cookiein the response orAuthorizationin the request means an automaticBYPASS. If the gateway checks a token, it has to stripAuthorizationand pass the identity inprops.- For HTML use the same headers as in Cache policy:
max-agewithstale-while-revalidateinCloudflare-CDN-Cache-Control, nevers-maxageormust-revalidatefor 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.default | Workers Cache + gateway | |
|---|---|---|
| Billed requests per page or file | 1× | 2× |
| CPU on a HIT | the worker runs (lookup + header copy, typically about 1 ms) | only the gateway, the cached entrypoint does not run |
| Reach of the cache | one data center | lower + upper tier across the network |
| Concurrent MISSes | each one renders or reads R2 | collapsed into one call per data center |
| Stale copy (SWR) | no | yes |
| Purge | not across data centers | ctx.cache.purge() by tag, prefix or everything |
| After a deploy | the 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
| Situation | Recommendation |
|---|---|
| One domain, or responses that do not depend on the host | Turning it on is enough: "cache": { "enabled": true }, workers_dev: false and an explicit no-store for errors (Setup). |
| Several domains, host-dependent HTML, expensive MISSes | Gateway with the host in ctx.props, count on 2× requests. |
Several domains, host-dependent HTML, cheap HITs in caches.default | Stay with caches.default. Turning the cache on without a gateway breaks the canonical. |
| Pages that differ per user only by a cookie | Not suitable, cookies are not part of the key. |
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-AgentorAccept-Language. Whatever changes the response must go intoprops, or the response must not be cacheable. - Every response that must not be cached sends
no-storeorprivate. A missing header is not an opt-out. workers_dev: false, so*.workers.devdoes 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
Rangeheader, asks the worker for the full 200 and slices it itself. A206returned by the worker is never stored. - Pre-compressed files (
.br,.gzwithContent-Encodingset by the worker): Cloudflare stores theContent-Encodingthe worker sets. CheckContent-EncodingandContent-Lengthon a HIT and a MISS before going live (for example, Unity loaders compute progress fromContent-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 is | inside the worker, which runs on every request | in front of the worker, which does not run on a HIT |
| Reach | one data center, each fills separately | lower tier + upper tier shared by the network |
| Key | your own (hash of _rsc, router headers, deploy id) | path + query + worker version |
| After expiry | the first visitor waits for a render | SWR: old copy now, refresh in the background |
| Purge | not possible across data centers, wait for the TTL | ctx.cache.purge() |
| After a deploy | cold cache (deploy id in the key) | cold cache (version in the key) |
| Billed on a hit | request + CPU | request 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
- Remove the
caches.defaultlookups, custom keys, hashing andVaryrewriting fromworker-entry.mjs. - Enable the cache in
wrangler.jsonc(step 2). - Set the cache policy in response headers, including the variant guard (step 3).
- Check with
Cf-Cache-Statusinstead of your own header such asX-Edge-Cache(Verify).
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
This guide is based on the Cloudflare documentation and on our production setup:
- Your Worker can now have its own cache in front of itThe Cloudflare launch post (July 2026).
- Workers CacheOverview, tiers, request collapsing and billing.
- Configurationwrangler.jsonc, header precedence, heuristic TTLs, bypass rules.
- Cache keysWhat is and what is not part of the key.
- Purgectx.cache.purge(), tags, path prefixes and limits.
- ExamplesGateway with ctx.props, Accept-Encoding normalisation.
- LimitationsHEAD, Range, what is never cached, no purge by host.
Related
- Next.js on CloudflareStatic build, OpenNext worker, D1, R2 and incremental cache.
- Cloudflare wikiAPI tokens, deploying with a token, R2 from the command line.
How to cite this publication (APA 7)Nepor, V. (2026, October 6). Cloudflare Workers Cache: Edge Cache for Next.js on Workers. Vrealmatic. https://vrealmatic.com/cloudflare/workers-cache
Need a faster site on Cloudflare?
We set up Workers Cache, OpenNext and the whole Cloudflare stack for your project.