GioJSdocs
Architecture

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; ttl is the seconds until the entry goes stale
  • stale; age=<secs>; revalidating - served instantly from the cache past its TTL while one background render refreshes the entry; age is seconds since it was rendered
  • miss; stored - rendered by the Node worker and stored; the next request for this key is a hit
  • bypass - rendered (or redirected) but not cached: the page did not declare revalidate, the request was not GET/HEAD, the response varies per user, or it set per-request headers
  • static - 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:

bash
$ 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:

tsx
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 cached
  • ppr; shell=hit - cached shell served instantly, holes streamed behind it
  • ppr; shell=stale; age=<secs>; revalidating - stale shell served while a background render refreshes it