Skip to content

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.

PrimitiveLayerAuthor siteWhat it cachesCost
prerender: ['/about']buildvite.config.ts plugin optionWhole 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.rulesedgeper-page export and/or plugin configThe 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 tabreads the SSR response’s CDN-Cache-ControlThe 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 subpathA 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.

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 behaviorNetwork fetch?Worker invocation?
0 – max-ageHIT — serve cached entry from memoryNoNo
max-agemax-age + swrSTALE-SWR — serve cached entry immediately, fire background refresh to refillYes (background, non-blocking)Yes (background)
past max-age + swrMISS — fetch fresh, fill cacheYes (blocking)Yes (CDN may HIT)
(no max-age directive)Never cached — every nav fetchesYes (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 } }:

  1. User soft-navs to /products/123 → router fetches HTML → CDN response → router cache fills, CDN may also cache.
  2. User soft-navs to /, then back to /products/123 within an hour → router cache HIT → no fetch, no CDN hit, no Worker invocation, no network. Page swaps from memory.
  3. User soft-navs to /products/456 (different URL) → router cache MISS → fetch.
  4. 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.

“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:

LayerWhere it livesHonors which headerSaves a network GET?Saves a Worker invocation?
Router cacheBrowser tab in-memory (phaze-router)CDN-Cache-Control (preferred) or Cache-Control
CDN edge cache (Cloudflare)A Cloudflare PoP near the userCDN-Cache-Control (preferred) or Cache-Control: s-maxage=N❌ — the GET reaches the edge
Browser HTTP cacheThe user’s private browser cacheCache-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 invocation

The 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.href assignment). The router cache lives in JS memory; a full reload drops it. Browser HTTP cache (if enabled via cacheControl) 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 withRevalidate on a signal.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 rendered

Reading it:

  • Left branch (HIT) — the cheapest path. Browser memory only; no network, no Worker. The console.log HIT is 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.
  • revalidateTag purges the CDN. Open tabs holding router-cache entries don’t get the message; they continue serving the cached entry until their own TTL expires.

The headline caching surface. Either declare per-page or centrally in vite.config.ts:

// src/pages/products/index.tsx — per-page
export const revalidate = { maxAge: 60, swr: 300, tags: ['products'] }
// vite.config.ts — central, one source of truth
cloudflare({
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=300
Cache-Tag: products

CDN-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).

Given maxAge: 60, swr: 300, with t = seconds since the cache was last filled:

tWhat the CDN doesWorker runs?Loader queries D1/KV?
0 (first visit)MISS — invoke Worker, cache the response, serve itYesYes
0 – 60 sHIT — serve the cached HTMLNoNo
60 – 360 sSTALE-but-within-SWR — serve the cached HTML immediately, fire a background request that re-runs the Worker, replace the cache when it returnsYes (background)Yes (background)
> 360 sSTALE-past-SWR — block the request, invoke Worker, cache, serveYes (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.

┌─────────────────────────────────────┐
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 served

Tags: 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:

src/actions/index.ts
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.

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.

Both views exist; both compose. Precedence is layered with force:

  1. Plugin cache.rules is the baseline — defaults by glob. Most-specific glob wins; ties to config order.
  2. Per-page revalidate layers on top:
    • Timing fields (maxAge, swr, cacheControl): page replaces config.
    • Tags: page extends config (union — the product detail page is also products).
  3. force: true on 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.
vite.config.ts
cache: {
rules: {
'/products/*': { maxAge: 3600, tags: ['products'] }, // baseline
'/admin/*': { force: true, cacheControl: 'no-store' }, // enforced
},
}
src/pages/products/featured/index.tsx
export const revalidate = { tags: ['featured'] }
// → effective: { maxAge: 3600, tags: ['products', 'featured'] }
// (timing inherited; tags merged)
// src/pages/products/sale/index.tsx
export 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.tsx
export 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.

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), overrides max-age for 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
}

The name SWR appears in two unrelated places. Conflating them is a common stumble:

  • SWR-the-HTTP-directive (stale-while-revalidate=N in CDN-Cache-Control). What this section is about. CDN behavior, server-emitted, no JavaScript. Not a “client component” anything.
  • SWR-the-React-library (the swr npm package by Vercel). A client-side data-fetching hook (useSWR). In phaze, the equivalent pattern is signal.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.

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 client

Only 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.staleTimes in next.config.js, independent of the server-side revalidate. 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 changed dynamic from 30s to 0s) because the confusion was rampant in the community. Phaze uses the response’s own CDN-Cache-Control directive 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-Tag purge already targets, and curl -I shows the same way as any other response.
  • revalidateTag reaches everything coherently. Next’s revalidateTag invalidates Next’s own incremental cache; the client Router Cache invalidates via separate hooks (router.refresh() etc.). Phaze’s revalidateTag purges the CDN; the router cache’s lifetime is bounded by the same CDN-Cache-Control directive 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 with revalidate: { 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-Control for 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’s CDN-Cache-Control default keeps the cache out of browsers (and in the CDN + router cache layers), so revalidateTag purges 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.

  • prerender: + revalidate on 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,” revalidate covers it without prerender:.
  • maxAge without swr is fine but harsher. The visitor at t = maxAge + 1 s waits for a full Worker round trip. Almost always cheap to add swr at 5–10 × maxAge.
  • Don’t cache mutations. revalidate belongs on GET pages and endpoints. Phaze actions are POSTs — they invalidate the cache, they don’t get cached themselves.
  • A 'no-store' Cache-Control and a Cache-Tag together is wasted bytes. If a route is uncacheable, don’t tag it; the CDN never had anything to invalidate.