Rendering and cache

How Cusp pre-renders pages, serves them from the edge, and invalidates them precisely

Cusp serves the public site through a hybrid render path. The expensive work — turning a document into HTML — happens once, at publish time, and the result lands in R2. After that a request is answered from Cloudflare's edge cache where possible, from R2 when the cache misses, and from a live render only as a last resort. A reader almost never waits on a render.

The three layers

A public GET passes through three layers in order. The first to answer wins, so the common case is the fastest.

  1. Cache API — Cloudflare's edge cache, accessed in the Worker via caches.default. A hit returns immediately with no origin work.
  2. R2 pre-rendered HTML — when the cache misses, the Worker reads the page that publishing already wrote to rendered/{path}/index.html, returns it, and writes it back into the Cache API for next time.
  3. Live render — when there is no pre-rendered entry yet (a fresh deploy whose seed has not finished, or a page that has never been published), the Worker renders the page on the fly from the current state and caches the result.

Live render is a fallback, not the path. In steady state nearly every request is answered by the first two layers.

Pre-render at publish time

Publishing a document is a Cloudflare Workflow, not a synchronous request. After verifying the document is still published, its render step writes the document's canonical URL to HTML at rendered/{path}/index.html. Later steps re-render the affected collection listing, index the document for search, notify subscribed plugins, and purge the affected surrogate keys — then a debounced aggregates step regenerates the home page, sitemap, and feed and issues a second conditional purge. Each step is independently retryable, so a transient failure resumes from where it stopped rather than re-rendering everything.

The render adapter

The render is a render adapter satisfying the render(document, context) shape. The shipped implementation is a set of TypeScript template functions in src/themes/default/templates.ts that return HTML strings. An installed third-party theme is loaded from R2 and run inside the Dynamic Worker Loader sandbox; if that fails for any reason, the adapter falls back to the bundled default templates so a page always renders. The defence is finer-grained than whole-theme fallback, too: the renderer normalises any block it does not recognise — an unknown type, or a malformed shape — to a placeholder before rendering, so a single bad block degrades to a placeholder rather than throwing and 500-ing the whole page. One bad block can never take a page down.

Cache headers and stale-while-revalidate

Every cacheable public response carries a Cache-Control header tuned to its shape and a Surrogate-Key header listing the tags that can invalidate it. HTML pages set max-age=0, must-revalidate for the browser — so a navigation always revalidates and an editor's change is never frozen in a visitor's browser — while an s-maxage directive gives the shared edge cache its own TTL and a long stale-while-revalidate window serves instantly while a fresh copy is fetched in the background. Sitemaps and feeds are not HTML, so they keep a browser TTL of their own.

  • Public document HTML — public, max-age=0, must-revalidate, s-maxage=3600, stale-while-revalidate=604800.
  • Public listing HTML — public, max-age=0, must-revalidate, s-maxage=600, stale-while-revalidate=86400.
  • Public sitemap and feed — public, max-age=3600, stale-while-revalidate=86400.
  • Content-hashed media and versioned theme bundles — public, max-age=31536000, immutable; the URL changes when the content does, so they never need purging.
  • Authenticated and preview responses — private, no-store; never cached at the edge.

Surrogate-key purges

Cusp uses a named surrogate-key namespace rather than letting routes invent ad-hoc tags. A document page is tagged cumulatively — its own key plus the keys of everything it appears in — so a single change can purge exactly the pages it touches and nothing more.

  • doc:{id} — a document's canonical URL and API representation; purged on publish, unpublish, republish, or delete of that document.
  • listing:{collection} — collection index pages and the collection API listing; purged on any publish, unpublish, or delete in the collection.
  • tag:{slug} and taxonomy:{slug} — reserved for tag and taxonomy pages; taxonomies are on the roadmap, so no rendered page carries these keys today.
  • author:{userId} — reserved for author profile and listing pages; no rendered page carries this key yet, so author-scoped purges are no-ops until those pages land.
  • sitemap and feed — purged on any change that affects the site map or a collection feed.
  • theme, settings, and nav — attached to every cached HTML page because they affect every page; purged on theme switch, a render-affecting setting save, or a navigation change.

The purge mechanism works on the free and paid plans without an Enterprise feature. Each key has a KV registry under cache-keys:{key} holding the URLs cached under it. To purge a key, the Worker reads its registry and calls caches.default.delete(url) for each URL. This is the colo-local layer: the publish workflow's purge step and the admin handlers that mutate render-affecting state both call into src/worker/render/purge.ts.

Draining prerenders for site-wide chrome

A surrogate-key purge clears the cache layer, but it does not touch the R2 origin underneath it. That distinction matters for anything baked into every page's stored HTML. The site chrome — the primary nav and section menus, the site title and description, the locale — and the active theme's token style block are all rendered into each page's prerendered blob, so clearing the cache alone would just let the next request re-read the same stale HTML from R2. Changing one of these inputs therefore drains the whole rendered/ prefix from R2, not a hand-picked set of paths: purgeAllPrerenders paginates the prefix and deletes every object, and the next request to any page renders live from current state with the new chrome and repopulates R2.

The two triggers that drain the whole prefix are a theme change (a token override write or reset, or a theme activation) and a render-relevant settings change (site.primary_nav, site.menus, site.title, site.description, site.locale, site.homepage_page_slug). A per-document publish never does this — it purges only its own narrow keys. Treating navigation and menu changes as a bounded two-path purge once let two pages in the same section show different sidebar menus; the drain-the-prefix rule is recorded in ADR 0055.

The global cache epoch for cross-colo invalidation

caches.default.delete() only evicts from the Cloudflare colo that ran the deleting request. On a custom domain behind a zone, a zone-level purge fans the eviction out across colos. A default workers.dev deploy has no zone, so a surrogate purge silently misses every colo except the one that served it. That produced a real bug: after seeding, a reader who had already loaded the empty home page from a different colo kept seeing it, because the seed's purge ran somewhere else.

The fix is a global cache epoch: a monotonically increasing integer kept in KV under cache:epoch. The Worker reads it once per request — memoized for about five seconds per isolate, so it is effectively free on the hot path — and stamps it onto every cached response as an x-cusp-cache-epoch marker header. The Cache API key itself stays the bare URL, so the colo-local surrogate purges still target the same entries.

On read, a cached entry is served only if its stamped epoch matches the current one. Bumping the epoch makes every colo's existing entries mismatch at once, so the next request anywhere re-reads fresh from R2 or live render and re-stamps. Old entries are simply ignored and age out via their TTL — no active cleanup is needed.

The epoch is bumped on genuinely site-wide events: the example-site seed completing, first-run setup, a render-affecting settings or navigation change, and theme token or activation changes. It also fires on a single-document takedown — an unpublish or delete — because a taken-down page must disappear from every colo promptly, and the narrow colo-local purge only reaches the serving colo. A single-document publish, by contrast, does not bump the epoch — that would needlessly invalidate the whole cache — and instead relies on its narrow colo-local surrogate purge plus stale-while-revalidate to heal any other colo.


Read about async work