Skip to content

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 (and X-CMS-Content-Version when 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), or site-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. purgeSite deletes the KV settings entry and the Cache API settings entry (and all page entries), but it cannot reach into a running isolate's in-memory Map. 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.