Caching Layers
In-process LRU over a persistent disk tier, per instance.
The page cache is layered: a bounded in-memory LRU (L1, 1000 entries) over an on-disk tier (L2) that persists entries across restarts. Lookups check memory first, then disk; all writes go to memory immediately and to disk in a background task. The disk tier is bounded, with the oldest files evicted past the limit.
The architecture includes a storage-backend seam (L3) where a shared cluster-wide tier could slot in, but no shared backend ships yet - the cache is per-instance. For multi-instance deployments, set GIO_DEPLOYMENT_ID to the same value on every instance so their caches agree on the deployment ID.
Observing the cache: X-Gio-Cache
Every response carries an X-Gio-Cache header saying which tier answered and why, so cache behavior is observable from any curl -I instead of reverse-engineered:
hit; ttl=<secs>- served from the Rust page cache without touching Node;ttlis the seconds until the entry goes stalestale; age=<secs>; revalidating- served instantly from the cache past its TTL while one background render refreshes the entry;ageis seconds since it was renderedmiss; stored- rendered by the Node worker and stored; the next request for this key is a hitbypass- rendered (or redirected) but not cached: the page did not declarerevalidate, the request was not GET/HEAD, the response varies per user, or it set per-request headersstatic- served by the Rust static file layer (public/ assets, hashed chunks, fonts); never touches the cache or Node
Internal /_gio/* endpoints are not stamped (/_gio/image reports its own image cache as HIT/MISS).
The CLI decodes the header for you:
$ gio cache explain /posts/1
GET http://localhost:3000/posts/1
status 200
x-gio-cache hit; ttl=42
→ Served from the Rust page cache without touching Node. "ttl" is the
seconds until this entry goes stale.Partial prerendering (PPR)
A cached page is fast but shared; a personalized page is per-user but pays full render cost on every request. PPR splits the page: everything before your <Suspense> boundaries (the shell) is cached in Rust and served instantly, while the Suspense content (the holes) re-renders per request - getServerSideProps reruns with the requester's own cookies - and streams into the same response behind the shell.
Opt in by exporting shell = 'cache' next to revalidate on a page with Suspense boundaries:
import React, { Suspense } from 'react';
export const revalidate = 60;
export const shell = 'cache';
export default function Page({ who }: PageProps): React.JSX.Element {
return (
<main>
<h1>Storefront</h1>{/* shell: cached, identical for everyone */}
<Suspense fallback={<p>Loading your cart…</p>}>
<Cart who={who} />{/* hole: re-rendered per request, streamed in */}
</Suspense>
</main>
);
}
export async function getServerSideProps(ctx: GsspContext) {
// Reruns for every request on a shell cache hit - cookies are per-user here.
return { props: { who: ctx.cookies['who'] ?? 'anon' } };
}On the first request the page streams normally; Node marks the pre-Suspense boundary and Rust captures and caches the shell bytes (composed exactly as that client saw them, capped at 4 MB). On a hit, the cached shell goes out immediately - the instant TTFB - and a fresh holes-only render appends the personalized chunks. Stale shells follow stale-while-revalidate like any other entry.
The contract: the shell must render identically for every visitor - same tree structure, same bytes. Only Suspense content may be personalized. React's Suspense replacement scripts target boundary IDs by tree position, and on a hit the cached shell and the holes come from different render passes - a shell that varies per visitor would mismatch. The page must also be shareable in the usual sense (revalidate set, no per-request response headers, no vary); a page that isn't falls back to plain streaming with a warning.
PPR degrades gracefully: if the holes render fails or times out, the body simply ends after the shell and the Suspense fallbacks remain visible - the user gets the cached page with "Loading…" states instead of an error.
X-Gio-Cache labels PPR responses distinctly:
ppr; shell=stored- full render served, and its shell was captured and cachedppr; shell=hit- cached shell served instantly, holes streamed behind itppr; shell=stale; age=<secs>; revalidating- stale shell served while a background render refreshes it