Router: Caching
Caching in phaze-cloudflare is three small primitives that compose. They live at different layers, do different work, and the only coordination point between them is the tag — a string label that lets you invalidate many cached things at once without enumerating their URLs.
The four primitives
Section titled “The four primitives”| Primitive | Layer | Author site | What it caches | Cost |
|---|---|---|---|---|
prerender: ['/about'] | build | vite.config.ts plugin option | Whole pages → static HTML at build time, written to dist/client/<path>/index.html and added to _routes.json exclude. The Worker is never invoked for that path. | 0 B added to client or worker |
export const revalidate = … + cache.rules | edge | per-page export and/or plugin config | The SSR’d response at Cloudflare’s edge cache. Worker only runs on cache miss / SWR refresh. | 0 B runtime — just response headers |
Router cache (automatic when router: true) | client tab | reads the SSR response’s CDN-Cache-Control | The fetched page’s HTML subtree + __PHAZE_CF__ payload + title, in-memory by URL. Soft navs to a still-fresh route skip the fetch entirely. | ~400 B brotli in the router chunk |
withRevalidate(asyncSig, sec) | client tab | @madenowhere/phaze/revalidate subpath | A signal.async’s loaded value, refreshed periodically in the browser. Only the JSX nodes that read the signal re-render. | ~70 B per consumer chunk |
They compose. The CDN cache and the router cache are driven by the same directive (CDN-Cache-Control on the SSR response) — set revalidate: { maxAge: 60 } once and both layers honor it: the CDN serves up to 60 s of visitors without invoking the Worker, and within the same browser tab subsequent soft navs to that route skip even the network round-trip.
The router cache layer
Section titled “The router cache layer”When router: true is enabled, the router maintains an in-memory cache of fetched route responses keyed by URL. After a successful soft nav, the response’s CDN-Cache-Control (or Cache-Control) directives drive its lifetime in the cache:
| t (since cache fill) | Router behavior | Network fetch? | Worker invocation? |
|---|---|---|---|
0 – max-age | HIT — serve cached entry from memory | No | No |
max-age – max-age + swr | STALE-SWR — serve cached entry immediately, fire background refresh to refill | Yes (background, non-blocking) | Yes (background) |
past max-age + swr | MISS — fetch fresh, fill cache | Yes (blocking) | Yes (CDN may HIT) |
(no max-age directive) | Never cached — every nav fetches | Yes (every time) | Yes (CDN may HIT) |
Hard-capped LRU at 20 entries (older entries evicted on overflow). Cache is per-tab and per-session — lost on full reload, like a real CDN PoP rotating an isolate.
So with cache.rules: { '/products/*': { maxAge: 3600 } }:
- User soft-navs to
/products/123→ router fetches HTML → CDN response → router cache fills, CDN may also cache. - User soft-navs to
/, then back to/products/123within an hour → router cache HIT → no fetch, no CDN hit, no Worker invocation, no network. Page swaps from memory. - User soft-navs to
/products/456(different URL) → router cache MISS → fetch. - After an hour but within
max-age + swr→ STALE-SWR: instant swap from cache + background refresh updates the entry.
Invalidation: revalidateTag(ctx, 'tag') from an action purges the CDN cache for matching URLs. It does not reach in-flight browser tabs holding a stale router-cache entry — the cache layer is unaddressable from the server. Pick max-age values that match how fresh the data needs to be (short for actively-mutated content, long for static); for actively-mutated routes, prefer short max-age + generous swr so the next-visitor experience stays fast.
Dev observability: in pnpm dev:hmr, the router fires a phaze:router-cache CustomEvent on the document on every HIT / STALE-SWR (consumer code can listen if needed), and prints a [phaze:router] HIT /about (served from cache) line to the browser DevTools console (dev-only; production-stripped). The dev request log on the server side naturally shows no log line when a router HIT skipped the fetch — the absence is the signal that the cache worked.
Three cache layers, one directive
Section titled “Three cache layers, one directive”“Route caching” isn’t one thing — for a single page response there are three different layers that can serve it on the way to the user, each honoring a Cache-Control-family directive. The router cache is just one of them; understanding all three is the picture:
| Layer | Where it lives | Honors which header | Saves a network GET? | Saves a Worker invocation? |
|---|---|---|---|---|
| Router cache | Browser tab in-memory (phaze-router) | CDN-Cache-Control (preferred) or Cache-Control | ✅ | ✅ |
| CDN edge cache (Cloudflare) | A Cloudflare PoP near the user | CDN-Cache-Control (preferred) or Cache-Control: s-maxage=N | ❌ — the GET reaches the edge | ✅ |
| Browser HTTP cache | The user’s private browser cache | Cache-Control: max-age=N (NOT CDN-Cache-Control — browsers ignore it) | ✅ | ✅ |
Per-page revalidate: { maxAge: N } emits CDN-Cache-Control: max-age=N — the CDN edge and router cache layers both honor it. The browser HTTP cache is intentionally bypassed by this directive (it doesn’t read CDN-Cache-Control). To engage the browser cache too, opt in via the escape hatch:
export const revalidate = { maxAge: 60, // → CDN-Cache-Control: max-age=60 (CDN + router) cacheControl: 'public, max-age=5', // → Cache-Control: public, max-age=5 (browser)}Why phaze deliberately keeps the browser cache off by default: revalidateTag from an action can purge the CDN cache; the browser’s private cache can’t be reached from the server. A long browser-side max-age means a user holding the page in a tab/back-button stack sees a stale version regardless of any server-side invalidation. So phaze uses CDN-Cache-Control for the default maxAge (CDN + router only) and reserves browser caching for explicit opt-in.
The directive cascade. One revalidate: { maxAge: 60 } declaration produces:
revalidate: { maxAge: 60 } │ ▼CDN-Cache-Control: max-age=60 │ ├─► Router cache (in-tab): serves cached entries for 60s, no fetch └─► CDN edge cache: serves cached entries for 60s, no Worker invocationThe router cache layer sits above the CDN: a router HIT means the request never leaves the browser at all. A router MISS hits the network; the CDN may then HIT and serve cached without the Worker.
What the router cache does NOT cover:
- Hard navigations (typed URL, full page reload,
location.hrefassignment). The router cache lives in JS memory; a full reload drops it. Browser HTTP cache (if enabled viacacheControl) does cover this case. - Cross-tab sharing. Each tab has its own router cache. A second tab making the same nav re-fetches even if the first tab has it cached. CDN edge cache does cover this.
- Cross-session persistence. Closing the tab drops the cache. Browser HTTP cache + CDN edge cache cover this.
- Data refresh during a long-lived page view. The router cache covers navigation between pages; for a value that should refresh while the user stays on one page, use
withRevalidateon asignal.async.
The navigation flow through the cache layers
Section titled “The navigation flow through the cache layers”The diagram that’s hardest to keep in your head — what actually happens between a click and a rendered new page, and which cache layer can short-circuit the work:
USER CLICKS INTERNAL <a> │ ▼ ┌─────────────────────────────┐ │ phaze-router.navigate(url) │ │ • cacheKey = path + search │ └──────────────┬──────────────┘ │ ▼ LOOKUP in router cache (in-memory Map per tab) │ ┌──────────────────────────────┼──────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌────────┐ ┌──────────┐ ┌────────┐ │ HIT │ │STALE-SWR │ │ MISS │ │age ≤ MA│ │MA < age │ │no entry│ │ │ │≤ MA + SWR│ │or past │ │ │ │ │ │MA+SWR │ └───┬────┘ └────┬─────┘ └───┬────┘ │ │ │ ▼ ▼ │ swap from cache swap from cache │ console.log HIT + background fetch ──────────────────┤ (NO FETCH) console.log STALE │ │ │ │ ▼ │ FETCH(url, phaze-router: 1) │ │ │ ▼ │ ┌──────────────────────────────────────┐ │ │ PROD: Cloudflare edge cache │ │ │ • HIT → serve from edge │ │ │ • STALE-SWR→ edge serves + back-fill│ │ │ • MISS → Worker invokes │ │ │ DEV (vite middleware): │ │ │ • logical simulator labels response │ │ │ • Worker SSRs every time │ │ │ • [phaze:dev] HIT|MISS|STALE-SWR │ │ └──────────────────┬───────────────────┘ │ │ │ ▼ │ parse #__phaze_root__ │ + __PHAZE_CF__ payload │ + title │ │ │ ▼ │ fill router cache │ (if response has max-age) │ │ │ ▼ │ apply swap + view-transition │ │ └───────────────────────────────────────────────────┘ │ ▼ new DOM renderedReading it:
- Left branch (HIT) — the cheapest path. Browser memory only; no network, no Worker. The
console.log HITis the dev-time signal; the Vite terminal stays silent because the request never left the browser. - Middle branch (STALE-SWR) — best-of-both-worlds path. The user sees the page instantly (memory swap), and a background fetch refreshes the entry for the next nav. The terminal logs the background fetch under the same URL.
- Right branch (MISS) — the full round-trip. Fetch goes out; in production a CDN cache may HIT on it (saves Worker invocation but not the network), or MISS through to a fresh Worker invocation. In dev, the Vite middleware logs what state a real CDN would have served, then SSRs anyway. The response fills the router cache so the next nav to this URL becomes a HIT.
- STALE-SWR feeds back into MISS for the actual fetch — the background refresh uses the same fetch + parse + fill code path; it just doesn’t apply a swap.
What’s not in the diagram but worth noting:
- Hard navs (typed URL, Cmd-R,
location.href) skip the router entirely. Goes straight to the FETCH box (browser ↔ CDN ↔ Worker). The router cache is bypassed because the browser tears the JS context down on a hard nav. - Prefetched routes (when
prefetch: true) populate the router cache on viewport-entry, so the FIRST click on a prefetched link is already a HIT. revalidateTagpurges the CDN. Open tabs holding router-cache entries don’t get the message; they continue serving the cached entry until their own TTL expires.
Edge cache: maxAge + swr
Section titled “Edge cache: maxAge + swr”The headline caching surface. Either declare per-page or centrally in vite.config.ts:
// src/pages/products/index.tsx — per-pageexport const revalidate = { maxAge: 60, swr: 300, tags: ['products'] }// vite.config.ts — central, one source of truthcloudflare({ cache: { rules: { '/products/*': { maxAge: 3600, swr: 86400, tags: ['products'] }, '/api/*': { maxAge: 600, swr: 60 }, '/admin/*': { force: true, cacheControl: 'no-store, private' }, }, },})The plugin compiles the rules at config-resolve time and the worker merges them with each page’s revalidate export at request time — no source transform, no per-page headers emission. The wire output is a plain CDN-Cache-Control response header (plus Cache-Tag when tags are declared, and Cache-Control when the cacheControl escape hatch is used):
CDN-Cache-Control: max-age=60, stale-while-revalidate=300Cache-Tag: productsCDN-Cache-Control is the modern, CDN-only header — Cloudflare honors it, browsers ignore it. So maxAge here means “cache at the edge for N seconds”, with no risk of a user’s browser holding the page in its private cache (where revalidateTag can’t reach it).
The CDN’s decision table
Section titled “The CDN’s decision table”Given maxAge: 60, swr: 300, with t = seconds since the cache was last filled:
| t | What the CDN does | Worker runs? | Loader queries D1/KV? |
|---|---|---|---|
| 0 (first visit) | MISS — invoke Worker, cache the response, serve it | Yes | Yes |
| 0 – 60 s | HIT — serve the cached HTML | No | No |
| 60 – 360 s | STALE-but-within-SWR — serve the cached HTML immediately, fire a background request that re-runs the Worker, replace the cache when it returns | Yes (background) | Yes (background) |
| > 360 s | STALE-past-SWR — block the request, invoke Worker, cache, serve | Yes (blocking) | Yes (blocking) |
The swr window is what makes a cache hit feel instant even when the data is older than maxAge. The 61-second visitor sees the page in 5 ms (stale) and the background refresh updates the cache for the next visitor. Without swr, that visitor pays the full Worker round trip.
The request lifecycle
Section titled “The request lifecycle” ┌─────────────────────────────────────┐USER ── GET /products ─│ CLOUDFLARE EDGE CACHE │ │ reads CDN-Cache-Control, Cache-Tag │ │ decides: HIT / STALE-SWR / MISS │ └──────┬───────────────┬───────────────┘ │ │ HIT │ │ MISS / SWR background refresh │ ▼ │ ┌─────────────────────────────┐ │ │ CLOUDFLARE WORKER │ │ │ (your phaze app) │ │ │ │ │ │ loader({ env }) { │ │ │ env.DB.select(...) │ ← bindings live here │ │ } │ │ │ → SSR'd HTML + │ │ │ Cache-Tag: products │ │ └──────────┬───────────────────┘ │ │ ▼ ▼ served to user cached, response also servedTags: revalidateTag and on-demand invalidation
Section titled “Tags: revalidateTag and on-demand invalidation”A tag is a label you stick on cached responses so you can invalidate many of them at once without knowing their URLs.
Imagine a store with five routes that all read the products table: /products, /products/123, /products/featured, /api/products, /admin/products. All five tag their cached responses products (via the plugin config rule or a per-page revalidate). When a mutation lands, one call invalidates all five:
import { newAction } from '@madenowhere/phaze-cloudflare/actions'import { revalidateTag } from '@madenowhere/phaze-cloudflare'
export const createProduct = newAction({ handler: async (input, ctx) => { await ctx.env.DB.insert('products', input) await revalidateTag(ctx, 'products') // every cached entry tagged 'products' → evicted return { ok: true } },})
export const updateProduct = newAction({ handler: async ({ id, ...rest }, ctx) => { await ctx.env.DB.update('products', id, rest) await revalidateTag(ctx, `product:${id}`) // surgical: only this product's pages return { ok: true } },})The writer (createProduct) never had to enumerate which pages care about products. The five pages declared their interest with a tag; the action declared the event with a name. The string 'products' is the entire contract.
USER ── POST /transport/action/createProduct ──> WORKER ── env.DB.insert(...) └─ revalidateTag(ctx, 'products') │ ▼ ┌────────────────────────────────┐ │ CF Cache Purge │ │ Every entry tagged 'products' │ │ → evicted from the edge cache │ └────────────────────────────────┘ │ ▼ Next GET /products misses cache, Worker re-runs the loader, new product appears.Tags are global. revalidateTag('products') purges every cached entry with that tag — across pages, endpoints, layouts, anywhere. That global addressability is exactly the point: the writer doesn’t need to know the readers.
Dynamic per-route tags
Section titled “Dynamic per-route tags”For a [id] page, the tag value depends on the params. Use the function form:
export const revalidate = { tags: ({ params }) => [`product:${params.id}`],}updateProduct({ id: 123, … }) then calls revalidateTag(ctx, 'product:123') and purges only that product’s detail page, while a bulk re-categorisation that calls revalidateTag(ctx, 'products') purges all of them.
Per-page vs central rules
Section titled “Per-page vs central rules”Both views exist; both compose. Precedence is layered with force:
- Plugin
cache.rulesis the baseline — defaults by glob. Most-specific glob wins; ties to config order. - Per-page
revalidatelayers on top:- Timing fields (
maxAge,swr,cacheControl): page replaces config. - Tags: page extends config (union — the product detail page is also
products).
- Timing fields (
force: trueon a config rule flips it to enforcement: the page-level export is ignored, and the worker logs a one-time warning at cold start so silent overrides surface in the logs.
cache: { rules: { '/products/*': { maxAge: 3600, tags: ['products'] }, // baseline '/admin/*': { force: true, cacheControl: 'no-store' }, // enforced },}export const revalidate = { tags: ['featured'] }// → effective: { maxAge: 3600, tags: ['products', 'featured'] }// (timing inherited; tags merged)
// src/pages/products/sale/index.tsxexport const revalidate = 60 // shorthand for { maxAge: 60 }// → effective: { maxAge: 60, tags: ['products'] }// (page won on timing, config's tag carried over)
// src/pages/admin/users/index.tsxexport const revalidate = 300 // ignored — config has `force: true`// → effective: { cacheControl: 'no-store' }// cold-start warning in `wrangler tail`:// [phaze-cloudflare] revalidate at /admin/users ignored — the cache.rules entry '/admin/*' is force: true.The SRE view (open vite.config.ts, see every route’s policy at a glance) and the developer view (open a page, the rule sits next to the data it caches) both survive. Rule compilation happens once at config-resolve; the per-request merge is a cheap object lookup + 2-3 header sets — runtime cost on par with hand-writing the same headers export.
Naming: why maxAge and not sMaxAge
Section titled “Naming: why maxAge and not sMaxAge”max-age and s-maxage are both real Cache-Control directives, but they scope different caches:
max-age=N→ applies to all caches (browser and CDN).s-maxage=N→ applies to shared caches only (CDN/proxy), overridesmax-agefor those.
If a public page sets max-age=60, every user’s browser caches it locally — and revalidateTag can’t reach a private browser cache. That’s the trap.
Phaze sidesteps both by emitting the CDN-Cache-Control header instead of Cache-Control. CDN-Cache-Control is a CDN-only header (browsers ignore it entirely), so maxAge inside it cleanly means “max age at the edge” — same scope as s-maxage on the regular Cache-Control, just with the friendlier name.
When you actually want a browser cache, the cacheControl escape hatch lets you write the raw header:
export const revalidate = { maxAge: 60, // → CDN-Cache-Control: max-age=60 cacheControl: 'public, max-age=5', // → Cache-Control: public, max-age=5}Two completely different “SWR”s
Section titled “Two completely different “SWR”s”The name SWR appears in two unrelated places. Conflating them is a common stumble:
- SWR-the-HTTP-directive (
stale-while-revalidate=NinCDN-Cache-Control). What this section is about. CDN behavior, server-emitted, no JavaScript. Not a “client component” anything. - SWR-the-React-library (the
swrnpm package by Vercel). A client-side data-fetching hook (useSWR). In phaze, the equivalent pattern issignal.async(loader)+withRevalidate(sig, N)from@madenowhere/phaze/revalidate. Same idea — refresh data in the browser without a navigation — but signal-shaped.
When this doc writes swr: 300, it always means the HTTP directive. The client-side data revalidation pattern is described under client-side data revalidation below.
Client-side data revalidation
Section titled “Client-side data revalidation”Edge caching keeps the page response fresh on the server side. Sometimes you want a specific value in the page to refresh without reloading anything — e.g. a live order count in the header that updates every 30 s while the user stays on the same page. That’s withRevalidate:
import { signal } from '@madenowhere/phaze'import { withRevalidate } from '@madenowhere/phaze/revalidate'
const orderCount = signal.async(() => fetch('/api/orders/count').then(r => r.json()))withRevalidate(orderCount, 30) // refresh every 30 s on the clientOnly the JSX nodes that read orderCount() re-render when the signal updates — the surrounding tree doesn’t. SSR pre-fills the initial value (the worker awaits the loader during SSR), so the first paint already has data; the client revalidates from there. The effect re-arms its setTimeout on each pending → false transition, not via setInterval, so a slow loader can’t compound overlapping requests.
Compared to Next App Router & Astro ClientRouter
Section titled “Compared to Next App Router & Astro ClientRouter”The router cache + CDN-driven directive design has concrete wins over the comparable surfaces in the two most-cited prior-art frameworks.
vs Next App Router (staleTimes):
- One directive drives every layer. Next’s client-side Router Cache is configured separately, via
experimental.staleTimesinnext.config.js, independent of the server-siderevalidate. Two independent knobs for the same conceptual question (“how long is this route fresh?”) — and Vercel walked back the default twice in two major versions (Next 14 → 15 changeddynamicfrom 30s to 0s) because the confusion was rampant in the community. Phaze uses the response’s ownCDN-Cache-Controldirective to drive both the CDN cache and the router cache; one knob, no drift. - No RSC protocol on the wire. Next sends an RSC payload (a custom serialization format) for soft navs. Phaze sends the same HTML the CDN already knows how to cache — plain text the browser’s developer tools can read, the CDN’s
Cache-Tagpurge already targets, andcurl -Ishows the same way as any other response. revalidateTagreaches everything coherently. Next’srevalidateTaginvalidates Next’s own incremental cache; the client Router Cache invalidates via separate hooks (router.refresh()etc.). Phaze’srevalidateTagpurges the CDN; the router cache’s lifetime is bounded by the sameCDN-Cache-Controldirective so it ages out in lockstep. (Client tabs still hold stale entries until their TTL expires — same on both frameworks; no router cache can be remotely invalidated.)
vs Astro <ClientRouter />:
- Astro doesn’t maintain a JS-level route cache at all. Every soft nav re-fetches, every time. Phaze’s router cache means a stable route can be served zero-fetch within a tab for the configured
max-age. For a content site withrevalidate: { maxAge: 3600 }on the marketing pages, every cross-page nav within an hour is a memory swap. - No server-side cache invalidation primitive. Astro relies on browser HTTP
Cache-Controlfor soft-nav caching; that’s the user’s private cache, and you can’t purge it from the server. A CMS publish can’t invalidate cached pages already sitting in users’ browsers. Phaze’sCDN-Cache-Controldefault keeps the cache out of browsers (and in the CDN + router cache layers), sorevalidateTagpurges the CDN and the next nav fetches fresh. - Astro’s caching story is “use the browser’s HTTP cache and prefetch.” Phaze’s is “one directive cascades through three coordinated layers, all of which are invalidatable from a server action.” Same browser perf at the user end; vastly better cache coherence story for the operator.
In one line: phaze treats route caching as a stacked architecture driven by a single declaration; Next splits the layers into separate config surfaces, and Astro ships only the bottom (browser) layer.
Caching pitfalls
Section titled “Caching pitfalls”prerender:+revalidateon the same path is a build error. The two are different operational modes (static-at-deploy vs cached-at-edge), and conflating them silently leads to mystery behavior. Drop one or the other; if you want “static HTML that periodically refreshes,”revalidatecovers it withoutprerender:.maxAgewithoutswris fine but harsher. The visitor at t = maxAge + 1 s waits for a full Worker round trip. Almost always cheap to addswrat 5–10 ×maxAge.- Don’t cache mutations.
revalidatebelongs onGETpages and endpoints. Phaze actions arePOSTs — they invalidate the cache, they don’t get cached themselves. - A
'no-store'Cache-Controland aCache-Tagtogether is wasted bytes. If a route is uncacheable, don’t tag it; the CDN never had anything to invalidate.