Caching and CDN purge
How a published page gets from the renderer to a visitor, what Spillway caches on the way, which tags it files pages under, how a CDN in front is kept in step, and what to set where. The admin side is Site settings → Cache (getting-started.md); the endpoints are in api.md, Site; the internals in architecture.md.
The path of a request
- A visitor asks the CDN (if there is one) for
/about. The CDN serves its copy while it is fresh (s-maxage), serves a stale copy and refreshes in the background for a while longer (stale-while-revalidate), and otherwise asks Spillway. - Spillway looks the path up in its page cache, keyed by site and path. A hit is returned at once with
X-Spillway-Cache: HIT. - On a miss the page is rendered from the published revision, stored in the page cache together with its tags, and returned with
X-Spillway-Cache: MISS. - When content changes, the change invalidates tags: the page cache drops every entry carrying one of them, and, with a CDN connected, the same tags (or the URLs they stand for) are sent to the CDN.
The page cache is in process by default (500 entries) or shared through Redis with REDIS_URL (install.md). Query-dependent pages (exposed filters, calendars, term listings, search) are cached per path and declared query for a minute. Previews, the admin and the API are never cached.
Tags
Every rendered page carries the tags of what went into it:
| Tag | Set by | Invalidated when |
|---|---|---|
entity:<id> | every page, and every page that links or lists the entity | the entity is saved while published, published, unpublished, archived, restored or deleted |
type:<key>, list:<type> | every page of the type; listings of the type | an entity of the type changes; the content type is saved |
site:<id> | every page of the site | any entity of the site changes; the site settings or theme are saved, or a brand draft that changes the theme or the identity is published |
tgroup:<id> | every translation of a group (hreflang alternates) | a translation changes |
menu:<key> | pages rendering the menu (the shell renders main and footer) | the menu is saved, renamed or deleted |
shared:<key> | pages embedding the shared block | the shared block is saved or deleted |
collection:<id>, collection:<key> | Collection blocks and feeds | the collection is saved; an item of its type is published or unpublished |
form:<id> | pages with the form | the form is saved or deleted |
style:<block>:<key> | pages rendering a block in that block style, or storing that key (style:Hero:dark-band, style:events/agenda:compact) | a published brand draft adds, removes or changes the style, or moves the block's default to or from it (theming.md) |
brand-guide | the public style guide at /brand (with theme) | a brand draft is published; the voice is saved (theming.md) |
media:<id> | pages rendering the media item | the item's focal point changes; a DAM webhook bumps its version; the file is copied local |
term:<id> | term pages and pages filtered by the term | the term or its vocabulary is saved |
audience:<key>, experiment:<key> | pages with Personalize or Experiment islands | the audience or experiment is saved |
theme | the theme stylesheet (/api/theme/tokens.css) and every page | the site settings or theme are saved, or a brand draft is published (Brand previews with ?__brand=draft are never cached) |
search | the search page | the index is rebuilt |
registry, registry:<name> | registry blocks, and on a registry host the public registry JSON | a package is published, reviewed, yanked, deprecated or gets an advisory |
The tags are visible on every public response:
Cache-Tag: entity:0192...,type:page,site:default,menu:main,menu:footer
Surrogate-Key: entity:0192... type:page site:default menu:main menu:footer
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
Vary: Accept-Encoding
X-Spillway-Cache: HIT
Cache-Tag is what Cloudflare (Enterprise) and most tag-aware caches read; Surrogate-Key is the same list separated by spaces, the header Fastly and Varnish read. max-age=0 keeps browsers honest. Cached pages and public render responses keep a brotli and a gzip copy beside the HTML, made by the first hit that asks for each and dropped with the entry, so a hit never compresses; each copy has its own ETag. Vary is only Accept-Encoding: a public page is the same for every visitor, and varying on Cookie or User-Agent would split the CDN's cache per person. Media files and hashed admin assets are immutable for a year; their URL changes when the file does.
Cache settings
Site settings → Cache holds, per site:
- s-maxage and stale-while-revalidate (seconds): the
Cache-Controlof public pages, feeds, the theme stylesheet and the public render API. Defaults 60 and 300. - Provider and its credentials, the zone or service id, and the purge options below.
- Purge everything when the theme or the site settings change: a theme save purges the whole CDN instead of the
site:andthemetags (assets and untagged responses go too).
How to set them depends on what sits in front of Spillway. Read the rows as degrade steps: the further down the table, the less the CDN helps and the shorter the headers should be.
| Setup | s-maxage | stale-while-revalidate | Why |
|---|---|---|---|
| Tag purge (Cloudflare Enterprise, Fastly) | long: 3600 or more | 86400 | every change purges exactly the affected pages within seconds, so the edge can hold pages for hours |
| URL purge (Cloudflare on other plans, Bunny, the generic webhook) | medium: 300 to 900 | 3600 | changes still purge, but a site-wide change costs one call per 30 URLs or a full purge, so let the edge refresh pages on its own too |
| A CDN without a provider connected | short: 60 | 300 | nothing purges the edge; the defaults bound how stale a page can be |
| No CDN | any | any | the headers are read by nothing; the page cache does the work |
Without BASE_URL or a site base URL (or a primary domain), URL providers cannot build absolute URLs: the test endpoint says so, and a purge that needs URLs is marked failed with that hint.
The purge hook
With a provider connected, every tag invalidation reaches the CDN:
- The page cache's
invalidateTagsis wrapped: the tags are collected for two seconds (a publish evicts three to five tags; a kit install evicts hundreds), so one change is one purge. - At the end of the window the tags are resolved per site with a provider.
site:<id>names its site,entity:<id>is looked up, every other tag (a menu, the theme, a shared block, the registry) concerns every site that has a provider. - For each site a purge is composed to fit the provider:
- a tag provider receives the tags;
- a URL provider receives URLs: the entity's path with its locale prefix, every published translation's path (the locale variants), the locale roots when the entity is the home page or one of its translations, the pages this process had cached under the tags (so an unpublished or deleted page is still purged), and, for any site-wide tag (
site:,type:,list:,menu:,theme,shared:,media:,term:, ...), every published URL of the site. Past 500 URLs the purge becomes everything; - the generic webhook receives both.
- One row per site goes into the
cdn_purgequeue and is sent right away, off the request. A failure is retried after 30 seconds, 2 minutes and 10 minutes by the same ticker that delivers notifications (orspillway run-scheduled), then marked failed. Every settled purge writes acdn.purgeaudit entry (actor: who asked, or none when automatic) and stays in the Last purges log under Site settings → Cache (the last 200 per site).
Nothing in this chain blocks the request that made the change: the collection is synchronous and cheap, the resolution and the calls run later.
Multi-instance hosts: every instance purges what it invalidated itself, and Redis invalidations received from another instance are not purged again, so one change is one purge whichever instance took the write. The "pages this process had cached" part of a URL purge is per instance; a site-wide tag covers the rest.
Purging by hand
- Site settings → Cache → Purge: tags, paths or URLs separated by spaces, or Purge everything.
POST /api/site/cdn/purge { tags?, urls?, everything? }withsite.settings(paths are allowed inurls; an empty request purges everything).spillway cdn purge [--all] [--site KEY] [--tag T]... [--url U]...from the terminal.
Providers
Cloudflare
Create an API token with the Zone → Cache Purge permission on the zone (My Profile → API Tokens), enter it with the zone id (Overview page of the zone), then Test connection: it reports the zone name and plan.
Cloudflare does not cache HTML by default. Add a cache rule (Caching → Cache Rules) for the site's hostname that sets the cache eligibility to Eligible for cache and lets the origin's Cache-Control set the edge TTL ("Use cache-control header if present"), or the older page rule Cache Level: Cache Everything with Edge Cache TTL: respect existing headers. Leave /admin*, /api/* and /_preview/* out of the rule (Spillway sends no-store on those, but a rule that bypasses them costs nothing). With that in place the s-maxage and stale-while-revalidate from the Cache settings govern the edge.
Two ways to purge:
- By Cache-Tag (tick "Enterprise zone"): one call per change, exact. Needs the Enterprise plan; the test endpoint warns when the zone is on another plan.
- By URL (the default):
filesin batches of 30 per call. Precise for a single page; a site-wide change purges the published URLs, or everything past 500. Cloudflare rate limits purge calls per zone; a429is retried after theRetry-Afterit sends, up to three times per batch, and the queue retries the whole purge later if that fails.
Fastly
An API token with the purge_select scope (and purge_all for "Purge everything") and the service id. Pages are purged by surrogate key: the Surrogate-Key header Spillway sends is exactly the list Fastly indexes, so a purge names the same tags the page cache evicts. "Purge everything" calls purge_all; a manual purge with URLs only goes through Fastly's per-URL purge. Fastly honors Surrogate-Control over Cache-Control when present; Spillway sends Cache-Control only, which Fastly reads as well.
Bunny
The account API key (Account → API) and, for "Purge everything", the pull zone id. Bunny purges by URL, one call per URL (five at a time), so prefer a longer s-maxage and let site-wide changes become "everything" rather than hundreds of calls.
Generic webhook
For any other edge (Varnish behind a small purge service, KeyCDN, CloudFront through a Lambda, a cache of your own): a purge URL and an optional signing secret. Spillway posts
{ "site": "default", "at": "2026-11-06T10:00:00.000Z", "tags": ["entity:0192...", "type:page", "site:default"], "urls": ["https://www.example.org/about"], "everything": false }
with Content-Type: application/json, X-Spillway-Event: cdn.purge and, when a secret is set, X-Spillway-Signature: sha256=<hex HMAC-SHA256 of the body> (the same scheme as the notification webhooks, verified in adapters.md). Answer 2xx; anything else is retried. Test connection posts { "test": true } with empty lists. The receiver gets both the tags and the URLs, so it can purge by key where the edge supports it and by URL where it does not.
The registry host
On a registry host (REGISTRY_HOST=1) the public registry JSON is tagged too: index.json, the package list, stats, advisories and publisher profiles carry Cache-Tag: registry; a package's detail, manifest and readme carry registry,registry:<name>. Downloads and files are immutable and untagged. Every registry mutation (publish, review decision, yank, deprecation, advisory, publisher change) evicts the same tags from the page cache and sends them to the provider, with the JSON URLs for URL providers, so a published version shows at the edge at once.
Headless front ends
The public render API (/api/public/*) carries the same Cache-Tag, Surrogate-Key and Cache-Control as the HTML, so a CDN between an Astro or Next.js front end and Spillway is purged the same way. The front end's own cache is told through the entity.published, entity.unpublished and entity.deleted webhooks, which carry cacheTags and publicPath (adapters.md).
Spillway 0.1.0 · Edit this page on GitHub