Improving CDN Caching with Cloudflare Workers
Cloudflare Workers run JavaScript or WebAssembly on Cloudflare's edge, in front of the cache. That position makes them useful for caching problems that static configuration cannot solve: cache keys that depend on a cookie or the visitor's country, A/B variants, or responses assembled from cached parts. It also makes them easy to misuse. This post covers how Workers interact with the cache and a few patterns that hold up in practice.
How Workers and the cache fit together
- Workers run before the cache. Every request that matches a Worker route invokes the Worker, cache hit or not, and counts as a Worker request for billing.
fetch()from a Worker goes through the zone's cache, including tiered cache, using the zone's cache settings and the origin's headers.- The Cache API (
caches.default) is local to one data center. Entries are not replicated to other locations and do not use tiered cache. Purging a Cache API entry by URL from inside a Worker also only affects that data center. - Limits. On the Free plan a Worker gets 10 ms of CPU time per request and 100,000 requests per day. On Paid plans the default CPU limit is 30 seconds and can be raised to 5 minutes. Time spent waiting on
fetch()is not CPU time.
Before writing code, check whether Cache Rules do the job. Bypassing cache for /api/*, caching HTML for anonymous users, or setting an edge TTL per path are configuration, and configuration is easier to review than a script.
Project setup
# wrangler.toml
name = "my-app-edge"
main = "src/index.js"
compatibility_date = "2025-08-01"
routes = [{ pattern = "www.example.com/*", zone_name = "example.com" }]
[observability]
enabled = true
[observability] turns on Workers Logs. Use npx wrangler dev locally, npx wrangler deploy to publish and npx wrangler tail to stream live logs. All examples use module syntax (export default { fetch }), which replaces the older addEventListener('fetch') service worker style.
Pattern 1: cache at the edge with fetch options
The simplest way to cache something the zone would not cache by default:
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method !== "GET" || !url.pathname.startsWith("/catalog/")) {
return fetch(request);
}
return fetch(request, {
cf: {
cacheEverything: true,
cacheTtlByStatus: { "200-299": 300, "404": 30, "500-599": 0 },
},
});
},
};
This uses the normal zone cache, so tiered cache and global purge work. cacheTtlByStatus overrides the origin's TTL, so use it only on paths you know are identical for every visitor.
A common mistake is setting Cache-Control on the response after fetch() returns and expecting that to enable edge caching. By then the cache decision for the subrequest has already been made. Changing headers on the outgoing response only affects browsers and other downstream caches.
A custom cacheKey in the cf options is available only on Enterprise plans. On other plans, use the Cache API for custom keys.
Pattern 2: A/B variants with the Cache API
Assigning variants in the browser makes every page uncacheable or causes flicker. Assigning them at the edge lets you cache one copy per variant:
const COOKIE = "ab_variant";
export default {
async fetch(request, env, ctx) {
if (request.method !== "GET") return fetch(request);
const cookies = request.headers.get("Cookie") ?? "";
const match = cookies.match(/(?:^|;\s*)ab_variant=(a|b)(?:;|$)/);
const variant = match ? match[1] : Math.random() < 0.5 ? "a" : "b";
const keyUrl = new URL(request.url);
keyUrl.searchParams.set("__variant", variant);
const cacheKey = new Request(keyUrl.toString());
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
const originRequest = new Request(request);
originRequest.headers.set("X-Variant", variant);
response = await fetch(originRequest);
const cc = response.headers.get("Cache-Control") ?? "";
if (response.status === 200 && !/no-store|private/.test(cc)) {
const copy = new Response(response.clone().body, response);
copy.headers.delete("Set-Cookie");
copy.headers.set("Cache-Control", "public, max-age=300");
ctx.waitUntil(cache.put(cacheKey, copy));
}
}
if (!match) {
response = new Response(response.body, response);
response.headers.append(
"Set-Cookie",
`${COOKIE}=${variant}; Path=/; Max-Age=2592000; Secure; SameSite=Lax`,
);
}
return response;
},
};
Details that matter:
cache.putonly accepts GET requests and never stores responses withSet-Cookie, so the stored copy has it removed. The response returned to the visitor keeps the origin's cookies.- The origin's
no-storeandprivateare respected. Never cache a response that depends on anything other than the URL and the variant. ctx.waitUntillets the cache write finish after the response is sent.- The same structure works for a country-specific cache: use
request.cf?.countryinstead of the cookie value. - Every extra key dimension splits the cache. Two variants double the entries, two variants times 50 countries multiply them by 100. Check the hit ratio after each new dimension.
Pattern 3: geo routing
request.cf carries the visitor's location as Cloudflare sees it:
export default {
async fetch(request) {
const url = new URL(request.url);
if (request.cf?.country === "DE" && url.hostname === "www.example.com") {
return Response.redirect(`https://de.example.com${url.pathname}${url.search}`, 302);
}
return fetch(request);
},
};
Use 302, not 301, because browsers cache permanent redirects and the visitor may travel or use a VPN. Offer a visible way back to the main site, and let crawlers reach every regional version.
Measuring the effect
CF-Cache-Statuson responses, with values such asHIT,MISS,EXPIRED,BYPASSandDYNAMIC. For Cache API entries, add your own header (for exampleX-Edge-Cache: hit) so you can tell the two caches apart.- Cache analytics in the dashboard, per path, before and after a change.
- Origin request rate and bandwidth from the origin's own metrics. That is the number the Worker is supposed to reduce.
- Worker CPU time and errors from Workers Logs and metrics. A Worker that throws on every request becomes the outage.
Pitfalls
- Testing the Cache API in the dashboard editor or Playground: cache operations there have no effect, so test on a real route.
- Caching personalised HTML because a key dimension was forgotten.
- Unbounded cache keys, such as including the full query string with tracking parameters. Normalise or drop parameters the origin ignores.
- Logic that grows into an application. Keep Workers small and stateless; use KV, D1 or Durable Objects if state is really needed.
Checklist
- Cache Rules first, Worker code only for logic the rules cannot express.
fetch()withcfoptions when the normal zone cache and global purge are enough.- Cache API for custom keys, knowing it is per data center.
- No
Set-Cookiein stored responses, originno-storeandprivaterespected. - Few, bounded cache key dimensions, hit ratio checked after each change.
- Workers Logs on, origin load measured before and after.
