Skip to content

Cloudflare: Rendering

Astrophaze-cloudflare
SSR engineAstro renderer + island hydrators per client:*@madenowhere/phaze-render-to-string (renderToString + linkedom)
Streamingyes — default in Astro 6.xyes — three-stage ReadableStream: openShell → SSR body → closeShell + payload
TTFB shapehead ships once head() resolveshead() and loader() run in parallel; first chunk lands as soon as head() resolves (loader continues in background)
Error mid-streamAstro catchescatches + emits visible <pre> marker and closes tags so the page doesn’t hang
Modulepreloadyesyes — <link rel="modulepreload"> in <head> + <script type="module"> at end of body
Hydration payloadper-islandsingle window.__PHAZE_CF__ JSON, < neutralised, </style> escaped

phaze-cloudflare (native) vs Phaze + Astro (Cloudflare) — render + hydrate performance

Section titled “phaze-cloudflare (native) vs Phaze + Astro (Cloudflare) — render + hydrate performance”

The feature-by-feature comparison above shows surface parity with bundle-size wins. This section is about a structurally different axis: rendering and hydration speed, where the wins come from architecture, not byte trimming.

The framing point — and the user-visible payoff — is that phaze-cloudflare has no server-island machinery. Astro’s island model is what makes “ship mostly-static HTML, hydrate small interactive components” possible; the cost is that every island gets a wrapper custom element, a serialized props envelope, a renderer-URL attribute, a connectedCallback that dyn-imports the renderer, and a slot-stitching protocol. phaze-cloudflare’s whole page is one component tree, hydrated by one entry. The island tax is structural — Astro pays it on every page, including pages with only one island.

Same TodoList page (KV-backed, two items in the list, live request), captured from each adapter’s production build:

astro-cloudflarephaze-cloudflareRatio
SSR’d HTML size15,973 B1,294 B12× smaller
Inline JS in HTML3,923 chars (custom-element class + props serializer)53 chars (window.__PHAZE_CF__={…})74× less
<astro-island> markers + per-island attrs1 wrapper, ~8 attributes, ~200 B of serialized props0n/a
<link rel="modulepreload">0 (Astro relies on per-island connectedCallback to dyn-import)1 (browser fetches client.js in parallel with HTML parse)
Client work before first paintparse 3.9 KB of inline JS to define the <astro-island> custom elementnone — client.js modulepreload runs while DOM parses
Client work before hydrationwalk DOM for <astro-island> instances, dyn-import each renderer module, dyn-import each component module, call hydratorone hydrate(rootComponent, root) call

The HTML payload size and the parse-before-paint inline JS are the headline numbers — they hit every page request, not just the one-off cold start. On a Cloudflare worker serving from edge, the 14 KB delta is roughly 10 ms of transfer time saved per request on a typical 4G connection, and 30+ ms of parse time saved on mid-tier hardware.

Astro’s island machinery is designed for the case where a mostly-static .astro template hosts a few interactive components. Every island gets:

  • A <astro-island> custom element wrapping its SSR’d HTML
  • An inline custom-element class definition (one per page, ~3.8 KB inlined script)
  • component-url, component-export, renderer-url, props, client, opts, await-children attributes per instance
  • A devalue-serialized props payload (handles Date / Map / Temporal — pays for it whether the page uses those types or not)
  • A connectedCallback flow: read attrs → fetch the renderer module → fetch the component module → call the hydrator → stitch slot DOM back in

phaze-cloudflare’s page IS the component. SSR emits the HTML and a single __PHAZE_CF__={…} payload. The client entry is the renderer; client.js loads via modulepreload, picks the page from the route table, calls hydrate() on the root. The page-vs-island duality dissolves.

This is the right trade for form-heavy, CRUD-style, app-like sites — exactly the workload Cloudflare Workers excels at. It’s the wrong trade for content sites that genuinely want most of the page to ship as inert HTML with one widget; that case is what improvement #1 below recovers.

The five changes below are sequenced by impact. They compose — none requires the others — and together they widen the gap from the “12× smaller HTML, 74× less inline JS” measured at the baseline to a comprehensive perf story across the four workload shapes (content-heavy, data-heavy, multi-page SPA, ISR). Each is covered below with its mechanism, measured deltas, and computed() callouts.

1. Static-subtree hoisting — Astro’s “static is free” advantage, taken back

Section titled “1. Static-subtree hoisting — Astro’s “static is free” advantage, taken back”

Without it, the whole page hydrates: a page that’s 90 % static layout + a small <TodoList/> still walks the hydration cursor through every node. The DOM doesn’t change, but the JSX construction cost is paid for every static <header>, <nav>, marketing <section>.

Opt in via cloudflare({ staticSubtreeHoist: true }). phaze-compile detects JSX subtrees that contain none of:

  • signal reads
  • on: / phaze: reactive attrs
  • class: / bind: / use: directives
  • ref={…} / key={…} / camelCase event props (onClick)
  • dynamic children ({expr} with anything other than a literal)
  • component JSX (capitalised tags)

Each eligible subtree is serialised to an HTML string at build time and replaced with staticSubtree(html) from @madenowhere/phaze/static. The compile pass auto-injects the import. Subtrees without a nested element child are left as JSX (the per-call-site overhead would exceed the saving).

At runtime: staticSubtree(html) returns a handle the JSX runtime recognises in child position. The handle’s resolve(parent) is called at append time (after the parent’s hydration frame is on the stack — which leaf-first JSX evaluation can’t guarantee at construction time). Two branches:

  • Hydration with cursor positioned inside parentskipNext() advances the cursor past the next SSR’d child and returns it. Identity preserved; no JSX construction; no per-element listener attach. computed() is not used herestaticSubtree is by definition non-reactive, so no memoization is needed; the value is a string baked at build time.
  • Otherwise (fresh render, or cursor misaligned because leaf-first eval placed us under a not-yet-adopted ancestor) — parse the HTML string into a <template> once and return its first child. The SSR’d counterpart, if any, gets pruned by the outer adoption’s exit(). The <template>.innerHTML parse runs once per staticSubtree(html) invocation per page — a single browser-native HTML parse vs N individual jsx() constructions.

The fresh-Node sibling case ([fresh, fresh, handle]) needed one new cursor primitive — advanceCursor() — so the static handle’s skipNext() consumes the correct SSR’d slot. Fresh siblings appendChild and bump the cursor without claiming adoption; the SSR’d counterparts get pruned by exit().

Measured (TodoList example, with hoist on):

  • phaze: 2,681 → 2,825 B brotli (+144 B for skipNext / cursorIs / advanceCursor / staticSubtree)
  • Entry chunk: 4,315 → 4,395 B brotli (+80 B for inlined HTML strings + staticSubtree resolve sites)
  • Net: +224 B brotli total when enabled. 0 B when disabled — every new primitive tree-shakes.

Where the perf win shows up: content-heavy pages with mostly-static layout (marketing pages, blog index, about pages). The TodoList demo gains little because most of the page is interactive. Hydration walltime drops because the static subtrees:

  • skip N jsx() construction calls per subtree (no object allocation, no applyProp iteration)
  • skip N peekChild + adopt + exit cursor walks
  • skip N listener attachments — for the about page’s 4-paragraph + nested-<code> blocks, that’s ~20 jsx() calls compressed into one browser-native HTML parse

Limitations of v1:

  • A few hydration primitives (skipNext, cursorIs, advanceCursor) live in core’s hydrate.ts for now rather than the @madenowhere/phaze/static subpath — needed direct cursor access that current subpath exports don’t compose into. Marked temporary; the surface will migrate to the subpath once the shape is stable.
  • The compile pass is opt-in (default false). Once we have wider coverage of edge cases (custom data attrs, SVG namespacing, style={{…}} objects), the default can flip.

2. Suspense-style SSR via signal.async — server islands without server-island machinery

Section titled “2. Suspense-style SSR via signal.async — server islands without server-island machinery”

Without it, the SSR renderer is synchronous: a component that reads signal.async(loader) sees pending=true throughout SSR, the loader’s Promise doesn’t resolve before the response is sent, and the loader fires AGAIN on the client at hydrate-time — doubling the request and showing pending UI until the second fetch returns.

renderToStringAsync(component, options) — a drop-in async variant of renderToString that awaits every in-flight signal.async loader before serialising. Internals:

  1. SSR-only capture in core’s signal.ts. When __beginSSRAsyncCapture() is open and the SSR bundle is in scope (import.meta.env.SSR), each signal.async(loader) invocation pushes its loader’s Promise into a module-scoped capture list. Client bundles strip the capture path entirely — esbuild constant-folds import.meta.env.SSR to false and tree-shakes the dead branches. Zero shipped client bytes.
  2. Drain loop in renderToStringAsync. After the synchronous render returns, drain the captured promises with Promise.allSettled + a microtask flush; the value/error signals update; the existing reactive bindings update the DOM in place. Loop until the capture list is stable (handles cascading async — a loader that triggers another signal.async).
  3. renderToStringAsync is now the default in phaze-cloudflare’s server pipeline. The synchronous renderToString stays exported as an escape hatch. Pages that don’t use signal.async see no behaviour change — the capture list is empty, the await settles instantly.

Where computed() improves perf here: when a component’s render reads multiple derived values off the same async signal (s.value()?.user.name + s.value()?.user.role + …), wrap the derivation in a single computed() and read THAT from JSX. The drain loop re-runs the reactive bindings after promises settle; with computed(), the derivation runs once per signal change and the cached result fans out to all readers. Without computed(), each binding re-derives independently. The doc’s signal.async patterns now consistently use computed() for multi-binding derivations — see Reactive Data / API Fetch for the canonical shape.

Measured (TodoList example, with renderToStringAsync wired into the server):

  • phaze: unchanged at 2,825 B brotli (with static-hoist) — the SSR capture is fully gated on import.meta.env.SSR and dead-codes on the client.
  • Worker bundle: 99,743 → 100,295 B brotli (+552 B for the awaitAsync drain loop in phaze-render-to-string).
  • Per-request behaviour for pages with signal.async: response time is max(head, loader, async) instead of loader + a second client-side fetch.

Not yet — true HTTP-chunk streaming with placeholder swap-in. The current implementation blocks the response until all async promises settle (max(head, loader, …async) wall-time). For pages where one slow async would otherwise hold FCP, a future iteration emits a <template id="phaze-async-N"> placeholder, ships the static parts immediately, then streams swap-in <script> chunks as each async resolves — the signal.ts capture list is already keyed for it; the SSR-side chunk protocol + client-side swap listener are the missing piece.

3. Signal-based router — eliminate re-hydration on SPA nav (opt-in via src/app.tsx)

Section titled “3. Signal-based router — eliminate re-hydration on SPA nav (opt-in via src/app.tsx)”

Without it, the router’s swap() does root.innerHTML = newRoot.innerHTML; startClient(routes): the entire scope tears down, every binding re-attaches, every effect re-fires. Layout chrome (nav, footer, ambient theme) re-mounts on every nav even when identical between routes.

An opt-in Phaze App via src/app.tsx. When present, the generated client entry passes the App default export to startClient(routes, App); the SSR pipeline and the prerender pass also wrap the page render in App. The canonical signature accepts a children prop and falls back to a currentRoute()-driven arrow:

src/app.tsx
import { currentRoute } from '@madenowhere/phaze-cloudflare'
export default function App({ children }: { children?: () => unknown } = {}) {
return (
<>
{/* persistent layout chrome can go here — header, nav, ambient
theme provider, in-app modals — anything that should survive
across SPA navigations stays OUTSIDE the dynamic-child arrow */}
{children ?? (() => {
const r = currentRoute()
if (!r) return null
const Page = r.module.default
return <Page data={r.data}/>
})}
</>
)
}

The children prop is the SSR/prerender path. Server-side, phaze-cloudflare invokes the App as App({ children: () => Page({ data }) }) — same shape from runtime SSR (server.ts) and the build-time prerender pass (prerender-render.ts). The LHS of children ?? … wins; the page renders into the SSR’d / prerendered HTML directly. An App component that ignores children will produce an empty __phaze_root__ body in SSR + prerender output (the currentRoute signal is client-only — it returns null in the worker / at build time). The example at examples/phaze-cloudflare/src/app.tsx demonstrates the canonical fallback shape.

The currentRoute() arrow is the client path. On the client mount, no children is passed; the RHS arrow becomes the dynamic child. currentRoute() is a signal that the router updates on every SPA nav. When it changes, ONLY the arrow’s body re-runs — everything OUTSIDE the arrow (layout chrome, signals declared at this scope, effects on outer elements) stays mounted.

computed() improves perf here in two ways the canonical pattern uses:

  • For derived values off the route — c(() => currentRoute()?.params.id), c(() => currentRoute()?.data as Profile) — multiple readers across the layout chrome share a single memoised lookup. Without computed() each binding re-runs the chain on every route change. Astro’s router has no equivalent — its router rebuilds the tree from scratch, so there’s no opportunity for cached derivations to survive.
  • The route signal itself is a signal<RouteState> (not a computed) — it’s set externally by the router, so memoisation doesn’t apply; subscribers fan out from the bare signal.

Mechanism:

  • New API surface — currentRoute() exported from @madenowhere/phaze-cloudflare. Returns null when no Phaze App is mounted (direct-mount mode).
  • New plugin discovery — src/app.tsx is probed at the project root; absent file means no Phaze App wrapper is generated, and startClient(routes) is called as before.
  • Router’s swap() branches on __hasAppShell(): Phaze App mode → __setCurrentRoute({ pathname, params, data, module }) and let the reactive subtree handle it; direct mode → the legacy innerHTML-swap + re-hydrate.
  • The Phaze App mounts ONCE at page load via the existing hydrate path. The dynamic-child arrow’s M3 hydrate logic adopts the SSR’d page subtree on first run; subsequent route changes go through the non-hydrating effect path (fresh page subtree mounted in place; old subtree’s effects dispose via orphan-scope teardown).

Measured (TodoList example with Phaze App wired in):

  • phaze: 2,825 → 2,828 B brotli (+3 B for the signal import in client.ts; the rest is folded into phaze already)
  • Client chunk: 4,395 → 4,617 B brotli (+222 B for the Phaze App wiring, route signal infrastructure, and app.tsx JSX compile output)
  • Net: +225 B brotli total when the Phaze App is in play. Apps without src/app.tsx see no change — the route-signal infrastructure tree-shakes via __hasAppShell()’s null check.

Performance win vs the previous re-hydrate path — for a hypothetical 10-route site with shared <Layout> chrome:

  • Re-hydrate model: every nav tears down the entire JSX tree + N event listeners + Layout’s effects, then rebuilds.
  • Phaze App model: only the page subtree rebuilds; Layout’s effects (theme toggle, scroll listener, MutationObserver, etc.) stay attached.

The persistence is most visible for layouts that own browser-native state — a focused search input in the header survives nav; an in-flight CSS transition on a sidebar continues; <video> in a sticky player keeps playing.

4. Per-route dynamic imports — smaller initial JS for multi-page apps

Section titled “4. Per-route dynamic imports — smaller initial JS for multi-page apps”

Without it, the generated client entry statically imports every page module: a 10-page site downloads JS for all 10 pages on every cold visit, even if the visitor only views /.

The plugin emits load: () => import('./Page.js') thunks in the route table instead of module: Page static references. Rollup code-splits each page into its own chunk. The initial bundle ships:

  • phaze (core runtime + static-hoist helpers)
  • client (the route table + startClient + optional startPrefetch / startRouter)
  • app (the Phaze App, if src/app.tsx exists)
  • The chunk for the initial page being viewed (Vite emits <link rel="modulepreload"> for it from the SSR HTML head, so the fetch starts in parallel with HTML parse)

Pages the visitor doesn’t navigate to are never fetched.

startClient is now async — it awaits the initial page’s load() thunk before hydrating. The SSR’d HTML stays visible during the wait (no FOUC); when the chunk resolves, hydrate adopts the same DOM. Module-resolution cache (a WeakMap<ClientRoute, PageModule> keyed by route) means re-visits don’t re-fetch.

The router (startRouter) calls __resolveRouteModule(route) on every nav — same cache, same lazy load. When prefetch: true is also enabled, the viewport-fetch already warms the HTML response and the page chunks (via <link rel="modulepreload"> in the prefetched HTML’s head), so by click-time the chunk is in browser cache and the import resolves synchronously.

computed() doesn’t appear in this layer — the route signal updates are coarse (whole-page-swap on nav), so a derived computed() would just gate on the same change. Where computed() shows up downstream is in user code reading currentRoute() for derived per-route state (see #3).

Measured (TodoList example with all four flags on):

Chunk (brotli)Before #4After #4
phaze2,828 B2,833 B (+5 B for dyn-import-cache)
client (entry + every page inlined)4,617 B
client (entry only — pages code-split)1,986 B
app chunk(folded into client)79 B
index page chunk (initial-page, code-split)(folded into client)1,880 B
about page chunk (lazy)(folded into client)1,446 B
blog page chunk (lazy)(folded into client)652 B
First-paint total for /7,445 B6,778 B (-9 %)
Fetched on visit to /about only0 (already loaded)1,446 B
Fetched on visit to /blog only0 (already loaded)652 B

For a 20-page real site the saving compounds — only the visited page’s chunk + the entry + the shared runtime gets fetched. The marginal-page cost is the per-route chunk; pages never visited cost zero.

Comparison vs Astro:

  • Astro’s per-island model already does some of this — each <X client:load/> lazy-loads its renderer chunk + component chunk via astro-island.connectedCallback. The cost is the per-island machinery (~3.8 KB of inline JS to define the custom element).
  • phaze-cloudflare gets the same code-split granularity for the page-level boundary without the per-island wrapper. The route table holds the thunks; the Phaze App coordinates the swap.

5. ISR via signal.async + withRevalidate — static + fresh data, same primitive

Section titled “5. ISR via signal.async + withRevalidate — static + fresh data, same primitive”
import { signal } from '@madenowhere/phaze'
import { withRevalidate } from '@madenowhere/phaze/revalidate'
const users = signal.async(() => fetch('/api/users').then(r => r.json()))
withRevalidate(users, 60) // refresh every 60 s on the client

Mechanism:

  • An effect subscribes to users.pending(). Each time pending falls to false (initial settle OR a revalidation completion), the effect arms a setTimeout(reload, seconds * 1000).
  • Re-arming chains on settle (not a recurring setInterval), so a slow loader can’t compound overlapping reloads.
  • SSR no-op: typeof window === 'undefined' short-circuits in the worker — no timer arms during the request lifetime.
  • Pairs with prerender: natively. The build-time render captures the initial value via signal.async (with the awaitAsync drain from #2); the client revalidates on the configured interval. Same primitive Astro spells revalidate in getStaticPaths, expressed via the signal model.

computed() doesn’t appear in the revalidate machinery itself (the timer/reload chain is structural, not derivational). It DOES show up in user code reading the revalidated value — c(() => users.value()?.length) etc. — for the same reason as #2: shared memoised derivations fan out cheaper than independent re-derives.

Kept on a subpath, not in core’s signal.async. Putting withRevalidate inline in signal.ts would have added ~76 B brotli to phaze even for apps that don’t use ISR. The subpath approach keeps the base signal.async at its current size:

  • phaze (no withRevalidate consumer): unchanged at 2,833 B brotli.
  • Apps that import { withRevalidate } from '@madenowhere/phaze/revalidate': chunk grows by ~70 B brotli per consumer chunk that uses it (it is carved out of the eager phaze chunk by default; with chunkSubpaths: true it splits into its own phaze-revalidate chunk instead of folding into the route).
  • Worker side (renderToStringAsync already captures the loader’s initial Promise, and typeof window === 'undefined' skips the timer): no extra cost beyond #2’s already-shipped drain loop.
WorkloadWinSurface
Content site (mostly static, one widget)#1 — static subtrees skip JSX construction at hydrate; the layout pays cursor-adopt cost only.cloudflare({ staticSubtreeHoist: true })
Data-heavy app (slow loaders)#2 — signal.async loaders await in SSR; the HTML ships with resolved data, no double-fetch on hydrate.Automatic in phaze-cloudflare’s SSR pipeline. Use signal.async(fetch) in components.
Multi-page SPA#3 + #4 — layout chrome persists across nav (Phaze App + currentRoute signal); only the visited page’s chunk fetches.Create src/app.tsx + add prefetch: true, router: true to plugin config.
ISR / partial-static#5 — periodic background reload of signal.async-loaded data.import { withRevalidate } from '@madenowhere/phaze/revalidate'

The computed() win pattern across the five — what shows up in canonical code:

  • #2/#3/#5: derived values off a signal that fans out to multiple readers should be computed(() => …) (or c(() => …) via the DSL). Astro’s no-signals architecture has no equivalent — every read re-derives.
  • #1/#4: structural — no derivational reactivity in the new code paths; computed() doesn’t appear in these layers.

The throughline: every improvement falls out of phaze’s signal model. There’s no new mental surface for the user — signal.async is already the suspense boundary; effect is already the hydrate hook; currentRoute is just another signal. Astro’s equivalents (server:defer, <ClientRouter/>, ISR via getStaticPaths + revalidate) are separate APIs with separate render contexts. phaze-cloudflare composes what’s already there.

Phaze-vendor stays sub-3 KB. The biggest core touch is #1’s cursorIs / skipNext / advanceCursor cursor primitives (+144 B brotli, tracked in hydrate.ts for migration to a subpath once stable). Everything else (/static, /revalidate, /ssr-internal) is opt-in subpath; apps that don’t use those features see no change. The phaze budget contract holds.