Data & state
Frozen pages
A pure page renders one time per content change, is stored whole, and is served as bytes until a publish thaws it. Render-on-write, not render-per-request.
On this page
Client islands made hydration opt-in per region. Frozen pages are the server mirror: execution becomes opt-in per request. A page whose render is a pure function of its URL renders one time, then freezes. It is stored whole: HTML, headers, permanent redirects. Each later request is a byte read. A publish thaws it, and the next request renders it again, one time. Kit, your loads, and Svelte never run on a hit. Cost moves from O(requests) to O(publishes). Prerendering is the special case where the only publish is a deploy.
ogygia({ freeze: true })That is the full adoption. The default store is an in-process LRU, with a 24-hour TTL backstop
(freeze: { ttl } to shorten it). Stampedes single-flight: fifty concurrent cold requests
make exactly one render.
Whole-app, or opt-in
By default (freeze: true) the whole app is on. Each eligible page is stored, and the
observed-purity check below refuses the rest, so a personalized page opts itself out. A single
route opts out with export const freeze = false.
To flip the whole app to opt-in, so that nothing is stored unless a route asks, set the default off:
ogygia({ freeze: { default: false } })Then a page or a layout opts in with export const freeze = true. It cascades like csr:
a layout opts in its whole subtree, and a page below can override in either direction. Deepest
declaration wins.
// this whole marketing subtree is stored
export const freeze = true;// …except this one preview page, which stays per-request
export const freeze = false;export const freeze is not one of Kit’s page options, so ogygia reads it and removes it
before Kit ever sees the module. There is no error, and nothing to configure. Opting a route in
only makes it eligible. An impure render is still refused by the purity check below.
Programmatic routes
A table from the server router carries the same flag, in its own grammar, and it cascades the same way: the page, then the nearest layout, then the table.
const marketing = layout('marketing', Shell, { freeze: true });
export const app = routes(
{
...marketing({
'/': page(Home), // frozen, from the layout
'/preview': page(Preview, { freeze: false }) // per-request again
}),
'/account': page(Account) // follows the config default
},
{ freeze: false } // optional table-wide default
);
export const handle = sequence(ogygiaHandle(), app.handle);Keep ogygia’s handle outermost in the sequence. The router answers inside it, so a frozen
hit is served before the table ever runs. The router normally stamps its documents cache-control: no-store. On an eligible request it leaves that to the verdict instead: a frozen
page gets public, s-maxage, a refused one gets private, no-store, and a header your page
sets itself always wins.
What gets stored: observed purity, not annotations
The handle watches each eligible render. A page stays per-request when the render:
- reads a cookie, personal header, or
localsvalue that returned a non-default value, - reads a feature flag (an A/B’d page must never freeze one variant),
- writes a cookie, sets
set-cookie, answers non-200, or streams load promises, - or was requested with a query string.
Each disqualification is named in a dev note (cookie:session, locals.user, flag:hero). Thus an almost-pure page tells you exactly which line keeps it hot.
Default-valued reads still store. The render of an anonymous visitor (cookies.get('consent') returns undefined, an empty locals.user) is the canonical copy. That is the cookie-less
CDN cache key, formalized. Once minted, all visitors receive the canonical. Content that must
truly differ per visitor belongs in a render: 'deferred' hole. The shell freezes. The hole
fetches fresh per visitor. Its signed URL is minted prerender-grade. Thus a warm frozen page never
carries expired holes (set a stable OGYGIA_SECRET).
Invalidation: three grammars
import { freeze } from 'ogygia/freeze';
await freeze.invalidate('/fr/fr/solar/'); // exact URL — the CMS-webhook case
await freeze.invalidateWhere({ prefix: '/fr/fr/' }); // subtree — a locale or section nuke
await freeze.invalidate(loadDoc, ['promo']); // declared source — see belowA successful form action also evicts its own page automatically. This mirrors the actions-invalidate-loads semantics of Kit.
Declared sources solve one-document-on-many-pages. Wrap the definition. Loads stay plain:
export const loadDoc = import.meta.og.source(async (slug: string) => fetchDoc(slug));Each stored page whose render called loadDoc('promo') files a receipt. freeze.invalidate(loadDoc, ['promo']) evicts exactly those pages, at the origin and at each edge, however many there are, in whatever locale they live. The macro follows the macro laws: top-level export const only, AST-verified, loud build errors.
Pass { key: (…args) => string } to canonicalize exotic argument shapes.
Stores and edges
Live objects cannot ride vite config. They are wired one time, in hooks:
import { freeze, valkey, akamai, cloudfront } from 'ogygia/freeze';
freeze.configure({
store: valkey(client), // or upstash({ url, token }),
edge: [akamai(creds), cloudfront(cfg)] // cloudflareKv(platform.env.KV)
});Stores: memoryStore (default) · valkey(client) (ioredis or node-redis) · upstash({ url, token }) (REST, serverless-native) · cloudflareKv(binding) (Workers KV).
Edges: akamai (EdgeGrid-signed Fast Purge) · cloudfront (SigV4 invalidations) · cloudflare (zone purge). Eligible responses are stamped cache-control: public, s-maxage=<ttl>, plus the tags of each edge. Thus your CDN follows
proven per-page headers, not a blanket rule. invalidate() fans out to the store and each edge
at the same time. One edge that is down never fails a publish, or a request.
Serve-time stitching
Sometimes even a one-beat canonical paint is too much: the first response itself must be
personal. Then mark the hole with stitch:
<script>
import Header from '$lib/Header.svelte' with { render: 'deferred', stitch: 'serve' };
</script>The shell still stores one time, and serves as bytes. But on each serve, the server renders
the hole at that moment, with your cookies in hand, and splices it into the stored HTML before
it responds. One response, personalized in view-source, no JavaScript required. The trade is
honest. Stitched pages are per-visitor. Thus they are stamped private, no-store (no CDN copy,
no 304s), and a hit costs one region render, not zero. Failure is open: when the render of the
hole fails or times out, the fallback ships, and the browser fetches it exactly like a normal
deferred hole.
Edge stitching
stitch: 'serve' buys a personal first paint at the price of the edge copy. On a CDN that
speaks ESI (edge side includes: Akamai, Fastly, Varnish) you can keep both:
<script>
import Header from '$lib/Header.svelte' with { render: 'deferred', stitch: 'edge' };
</script>The shell still stores one time, and it stays a normal edge-cached page: public, s-maxage,
validators and all. What changes is the hole. In the stored bytes it becomes an ESI include that
points at the hole’s own signed capability, and the response carries Surrogate-Control: content="ESI/1.0". On each request the CDN serves the shell from its cache,
fetches only the include from your server with the visitor’s cookies, and splices it at the edge.
The first response is personal, the shell never leaves the edge, and your server renders one
region, not a page. Enable ESI processing on the property for this to happen.
It degrades on its own. Anywhere ESI is not processed, direct to origin, in dev, or on a CDN
without it, the include is inert and the hole ships its fallback, then the browser fetches it
exactly like a normal deferred hole. One stitch: 'serve' hole on a page makes the whole page
origin-stitched, edge holes on it included.
Rule of thumb: render: 'live' when canonical-first is satisfactory. stitch: 'edge' when the
first paint must be personal and your CDN speaks ESI. stitch: 'serve' when it must be personal
and it does not.
Notes
- In dev, the store never serves or fills. Your edits always render again. The verdict
still teaches: an eligible page answers
x-ogygia-freeze: would-store(check the network panel). A refused one prints a single console note that names the disqualifying read. - Revalidations are free. Stored entries carry an
etagandlast-modified. The handle answersIf-None-Match/If-Modified-Sincewith a bodiless 304. Thus a CDN prefresh, or a browser reload of a stored page, costs zero body bytes. - Refused pages get
private, no-storewhen your app set no cache-control of its own. The headers of the verdict replace blanket CDN rules, in both directions. - csr-agnostic: a
csr = truepage stores the same way. Kit hydrates the stored copy. - The store holds the render, not data. A hit skips the data fetch and the component execution. Your data caches stay useful underneath, for the misses.
- Store failures never fail a request. A broken store means renders, not errors.
- Blind spot, the same contract as prerender:
Date.now()/randomness inside a load is “true at render time”.