GioJSdocs
On this page

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 by default) 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 (512 MiB by default), with the oldest files evicted past the limit. Both bounds and the disk directory are set in gio.toml:

toml
[cache]
memory_max_entries = 1000           # L1 size
disk_path = ".gio/cache/pages"      # L2 directory; GIO_CACHE_DIR overrides
disk_max_bytes = 536870912          # L2 cap; 0 = unbounded

On-demand purges (revalidateTag, revalidatePath and POST /_gio/revalidate) reach both tiers: a tag index covers entries in memory and on disk - including the ones a previous run left behind, indexed in the background at startup - so a purged page can never come back from disk.

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, and send on-demand purges (POST /_gio/revalidate) to every instance.

Persisted entries survive a restart only while the deployment ID stays the same. The derived ID covers the client build the server produces at startup (every chunk and stylesheet name is a content hash) and the app's server-side sources: every file under app/ - the root layout, metadata and revalidate exports, getServerSideProps, route handlers - plus middleware.ts, gio.config.ts, the project modules they import, the tsconfig and the lockfile. So a restart after a code or CSS change starts with an empty cache instead of serving pages the previous code rendered (or that link its deleted files), and a restart of the same code keeps the cache. Data your pages read at runtime (files, a database, .env values) is not part of the ID: after changing it, purge with revalidatePath() or POST /_gio/revalidate. It also covers the gio.toml settings pages are rendered with ([images] decides every GioImage srcset, plus the served [[fonts]] and [i18n] (locales and default_locale)), so changing those settings drops persisted pages too. A pinned GIO_DEPLOYMENT_ID is used as given: change it with every deploy.

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. A refresh that answers 404 (the page called notFound()) evicts the entry instead
  • 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 cache is off ([cache] enabled = false), the page did not declare revalidate, the request was not GET/HEAD, the response varies per user, it set per-request headers, or its getServerSideProps read the visitor's cookies, authorization header, IP (ctx.ip) or the host it asked for (ctx.host, ctx.scheme)
  • static - a file served by the Rust static file layer (public/ assets at the site root or under /public/*, hashed chunks, the CSS compiled at startup, the self-hosted fonts under /_gio/fonts/*); never touches the cache or Node

The server's own refusals - a rate-limit 429, a refused prefetch, a deployment-skew 409, a CSRF 403 - never reach the cache and carry bypass, also when they refuse a /_gio/* request (a rate-limited /_gio/image). Otherwise the 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:

app/shop/page.tsx
import React, { Suspense, use } from 'react';
import type { GsspContext, InferPageProps } from '@gio.js/core';
import { cartFor, type CartItem } from '../../lib/cart';

export const revalidate = 60;
export const shell = 'cache';

export default function Page({ who }: InferPageProps<typeof getServerSideProps>): React.JSX.Element {
  return (
    <main>
      <h1>Storefront</h1>{/* shell: cached, identical for everyone */}
      <Suspense fallback={<p>Loading your cart…</p>}>
        <Cart cart={cartFor(who)} />{/* hole: suspends, re-rendered per request, streamed in */}
      </Suspense>
    </main>
  );
}

// The hole waits for this visitor's cart, so React sends it after the shell.
// cartFor() stands for your data call (it runs again in the browser while
// the page hydrates).
function Cart({ cart }: { cart: Promise<CartItem[]> }): React.JSX.Element {
  const items = use(cart);
  return <p>{items.length} items in your cart</p>;
}

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. Cookies cannot be set from a holes render - the cached shell has already sent the response headers - so they are dropped with a warning; set them from a route handler or a non-PPR page.

Reading cookies in getServerSideProps is expected here. The hydration envelope (the serialized props) is streamed right after the shell boundary on every response, so each visitor hydrates with their own props. What the holes render from those props stays out of the shell only if the hole suspends: Suspense content that renders without suspending - or whose data arrives before the shell has been sent - is flushed with the shell. GioJS checks the shell before Rust stores it: when getServerSideProps read credentials and the shell holds rendered Suspense content (or no pending hole at all), the shell is not stored and a warning names the route; the page still streams, rendered in full for every request. Rendering those props outside a Suspense boundary breaks the contract - the first visitor's values would be cached in the shell.

A loading.tsx is a Suspense boundary too, around everything below its folder. On a PPR page whose content suspends, the cached shell therefore ends there: it holds the layouts above the loading.tsx and its loading UI, and the page itself streams per request as a hole. A page that renders without suspending is part of the shell, and the contract applies to it. A page that throws before it suspends is answered with its error.tsx and a 500 - a broken render is never stored as a shell. Neither is a shell holding a boundary React gave up on (an error inside any Suspense boundary before the shell was sent): that response still streams, but nothing is cached, and the next request renders again.

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.

getServerSideProps may still answer a visitor with something other than holes - redirect('/login') for a visitor who is not signed in, notFound(), an error. On a shell hit that answer comes after the shell's 200 has been sent, so the page finishes itself and takes the visitor there. A redirect to an http(s) or relative URL that sets no cookies becomes location.replace(url) (plus a <meta http-equiv="refresh"> for visitors without JavaScript). Anything else - a 404, an error page, a redirect that sets cookies - reloads the page once with a short-lived __gio_ppr_bypass cookie, and that request skips the cached shell: the whole page renders, with its real status, Location and cookies. The scripts carry the CSP nonce. The visitor still sees the shell for a moment first, so for pages most visitors are redirected away from, prefer a guard, which answers before any shell is sent.

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