Caching: the four layers¶
The worker is cache-first. A given response can be served from any of four independent cache layers, each with its own scope, lifetime, and key. Knowing which layer answered a request -- and which one can hold stale data -- is the key to reasoning about freshness and purge.
Source:
src/site/resolve.ts,src/cache/index.ts,src/cms/client.ts,src/assets/serve.ts,src/index.ts.
The layers at a glance¶
| Layer | What it caches | Scope | TTL | Key | Source |
|---|---|---|---|---|---|
| L1 isolate memMap | Site settings | One Worker isolate (in-memory) | None -- lives as long as the isolate | host |
src/site/resolve.ts |
| L2 Cache API | Site settings | Per Cloudflare PoP | 300 s (CACHE_API_TTL) |
synthetic https://cms-cf-worker.internal/site/<host> |
src/site/resolve.ts |
| L2 Cache API | Rendered pages | Per Cloudflare PoP | per-descriptor ttl |
https://<host><pathname><search> |
src/cache/index.ts |
| L3 KV | Site settings (fallback) | Global (KV) | 86400 s (KV_TTL, 24 h) |
site:<host> |
src/site/resolve.ts |
| L3 KV | Page envelopes (fallback) | Global (KV) | 7 days | <host>:<pathname> |
src/cache/index.ts |
L4 cf subrequest cache |
CMS API responses | Cloudflare edge | 30 s default; 86400 s for CSS | request URL (incl. ?v=) |
src/cms/client.ts, src/assets/serve.ts |
The two L2 rows and the two L3 rows are the same physical store (Cache API and KV respectively) holding two different kinds of value.
L1 -- isolate memMap (no TTL)¶
resolveSite keeps a module-scoped Map of host to settings. It is the first
thing checked and the fastest:
// Module-scoped per-isolate map. Survives across requests in the same isolate.
const memMap: Map<string, SiteSettings> = new Map();
const memHit = memMap.get(host);
if (memHit) return { settings: memHit, source: "mem" };
There is no TTL on this map. An entry survives for the life of the Worker isolate -- across many requests. The only ways it goes away are the isolate being recycled or evicted by the runtime, or a redeploy starting fresh isolates. This is the layer that can serve stale settings; see Stale settings below.
L2 -- Cache API (per PoP)¶
The Cache API (caches.default) is the per-point-of-presence edge cache.
Site settings are cached here for CACHE_API_TTL:
const CACHE_API_TTL = 300; // 5 minutes
const KV_TTL = 86400; // 24 hours
resolveSite checks the Cache API after the memMap, and on an origin fetch
write-throughs to both the Cache API (5 min) and KV (24 h):
ctx.waitUntil(cache.put(cacheKey, new Response(payload, { headers })));
ctx.waitUntil(env.KV.put(`site:${host}`, payload, { expirationTtl: KV_TTL }));
Rendered pages are cached here per descriptor. The page cache key is the
full URL including the search string, so query-driven variants (for example
?page=2) cache independently:
export function pageCacheRequest(host: string, pathname: string, search = ""): Request {
const url = `https://${host}${pathname}${search}`;
return new Request(url, { method: "GET" });
}
The page's lifetime is the matched descriptor's ttl. On a render, getOrRender
sets a cache-control header from it when the response does not already carry
one:
headersForCache.set(
"cache-control",
`public, s-maxage=${ttlSeconds}, stale-while-revalidate=${ttlSeconds * 2}`,
);
Note that the page cache key strips the query string in
pageCacheKey(host, pathname) (used for KV and purge), while the page Cache API
request keeps the search string -- so trackers do not fragment the KV envelope,
but legitimate pagination variants stay distinct at the edge.
L3 -- KV (global fallback)¶
KV is a global store used as the durable fallback when origin or render fails.
Site settings are written to KV with a 24 h TTL on every successful origin
fetch (above). If a later origin fetch fails, resolveSite replays the KV copy
and reports the source as kv-fallback:
const fallback = await env.KV.get(`site:${host}`);
if (fallback) {
const settings = normalizeSiteSettings(JSON.parse(fallback) as SiteSettings, siteId);
memMap.set(host, settings);
return { settings, source: "kv-fallback" };
}
Page envelopes are written to KV on every successful render with a 7-day TTL. The envelope stores the status, headers, and body so the page can be replayed verbatim:
await env.KV.put(pageCacheKey(host, pathname), JSON.stringify(envelope), {
expirationTtl: 60 * 60 * 24 * 7,
});
If rendering later throws, getOrRender serves the KV envelope, stamped
X-CMS-Cache: kv-fallback, instead of failing the request:
const kvHit = await env.KV.get(pageCacheKey(host, pathname));
if (kvHit) {
const envelope = JSON.parse(kvHit) as KvPageEnvelope;
const headers = new Headers(envelope.headers);
headers.set("X-CMS-Cache", "kv-fallback");
headers.set("Cache-Control", "public, s-maxage=60");
return { response: new Response(envelope.body, { status: envelope.status, headers }), source: "kv-fallback" };
}
If both render and KV miss, the error propagates and src/index.ts serves a
static 503 stub.
L4 -- the cf fetch subrequest cache¶
Every call to the CMS API goes through cmsFetch (src/cms/client.ts), which
sets Cloudflare's subrequest cache on the outbound fetch:
const DEFAULT_CF_CACHE_TTL = 30;
// ...
resp = await fetch(url, {
...opts.init,
method: opts.init?.method ?? "GET",
headers,
signal: controller.signal,
cf: { cacheTtl, cacheEverything: true },
});
cacheEverything: true caches the upstream response at the edge keyed by the
subrequest URL. The default TTL is 30 s (DEFAULT_CF_CACHE_TTL), so API
data the worker re-fetches within 30 s comes from the edge rather than origin.
Template CSS overrides the TTL to 86400 s (24 h). serveTemplateCss
(src/assets/serve.ts) fetches the stylesheet from the CMS with a long
subrequest cache, and the ?v=<version> in the URL is what busts it:
const path = `/public/v2/templates/${encodeURIComponent(safeSlug)}/css?v=${encodeURIComponent(safeVersion)}`;
// Long edge cache on the subrequest; the versioned ?v busts it on change.
const resp = await cmsFetch(path, env, { cacheTtl: 86400 });
Because the version is in the cache key, bumping css_version changes the URL
and so fetches fresh CSS without waiting out the 24 h TTL. See
CSS override.
The cache-first short-circuit¶
For non-preview requests, src/index.ts checks the page Cache API before doing
any work and returns the cached body immediately on a hit:
if (!isPreview) {
const cache = caches.default;
const cached = await cache.match(pageCacheRequest(host, pathname, url.search));
if (cached) {
const headers = new Headers(cached.headers);
headers.set("X-CMS-Cache", "hit");
headers.set("X-CMS-Render-Ms", String(Date.now() - startedAt));
return new Response(cached.body, { status: cached.status, headers });
}
}
This is the common path for public traffic: a warm page never touches the engine or the CMS API.
Preview bypass¶
A request with a valid ?_preview=<ADMIN_PURGE_SECRET> (optionally with
?_ver=) sets isPreview, which:
- skips the cache-first short-circuit (step 3);
- renders fresh and never reads or writes the Cache API or KV;
- stamps
X-CMS-Cache: preview(andX-CMS-Content-Versionwhen a version is given).
This lets editors see unpublished or specific-version content without polluting the public cache.
Observability headers¶
Every response carries:
X-CMS-Cache-- which layer answered:hit(page Cache API),miss(rendered fresh),kv-fallback(replayed from KV after a failure),preview(preview render),bypass(admin or asset endpoint), orsite-kv-fallback(settings came from the KV fallback tier).X-CMS-Render-Ms-- wall-clock milliseconds spent in the worker.
Why stale settings can persist¶
The L1 isolate memMap has no TTL. Once a host's settings are in an isolate's
memMap, that isolate keeps serving them until it is recycled -- regardless of
the 5 min Cache API TTL or the 24 h KV TTL beneath it. So:
- After a settings change in the CMS, a warm isolate can keep returning the old settings indefinitely. The lower tiers refresh on their own TTLs, but the memMap shortcut returns before they are consulted.
- A redeploy clears it. Deploying the worker starts fresh isolates with an empty memMap, so settings are re-fetched from origin (through the lower tiers).
- A purge clears the durable tiers, not L1 directly.
purgeSitedeletes the KV settings entry and the Cache API settings entry (and all page entries), but it cannot reach into a running isolate's in-memoryMap. New isolates pick up the change; already-warm ones do not until recycled.
The practical rule: a settings or CSS change that must be visible everywhere
now wants both a purge (to clear the edge and KV tiers and all pages) and,
for a per-worker config change such as the CSS override, a redeploy (which the
override change requires anyway -- it lives in wrangler.toml vars). See
CSS override and Deploy and purge.