- 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 → |
| 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 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).
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 | 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 |
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. Hit ratio and HIT / MISS / BYPASS counts are also in Workers Observability.
Checks after a deploy
# 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"# 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. |
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 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 |
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.
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.