GioJSdocs
On this page

Caching & Revalidating

Incremental Static Regeneration with stale-while-revalidate semantics and on-demand purges.

Export revalidate from a page to control how long its rendered HTML is cached by the Rust layer.

tsx
// cache for 60s, then revalidate in the background
export const revalidate = 60;

// cache for a year (until a purge or a new deployment, in practice)
export const revalidate = false;

// never cache (default)
// (omit the export)

How it works

Cached pages are served from memory in microseconds. When a page is past its revalidate age, GioJS serves the stale copy immediately and renders a fresh one in the background - but only until the page is [cache] swr_multiplier times revalidate old (10 times by default: up to 10 minutes for revalidate = 60). Past that window the entry is a miss, and the next request waits for a fresh render, as it always does with swr_multiplier = 0 (or 1). A page nobody visits for a while is therefore rendered on demand, not served from a very old copy.

revalidate = false is a one-year max age (31536000 seconds, the s-maxage of a fresh render): in practice the page stays cached until it is purged, evicted or a new deployment changes the cache key.

Cache keys are deployment-ID aware, and the derived ID changes with the app's client and server code, so a redeploy of changed code automatically invalidates stale entries. Data read at runtime (files, a database, .env values) is not part of the ID: purge after changing it. See Caching layers.

On-demand revalidation

revalidate bounds how long a page can be out of date. When you know the moment its data changed - a post was published, a price edited in the CMS - purge it right away instead. A purge removes the matching pages from the cache (memory and disk, PPR shells included): the next request for each is a miss and renders fresh, so nobody is served the old page after the purge returns. That is unlike the stale-while-revalidate refresh, which serves the stale copy one more time.

Tagging pages

Give a page tags to purge it by. Static tags (export const tags) apply to every render of the page; tags returned next to props from getServerSideProps are added per render:

app/posts/[id]/page.tsx
export const revalidate = 3600;
export const tags = ['posts'];            // every post page

export async function getServerSideProps(ctx) {
  const post = await db.posts.find(ctx.params.id);
  return {
    props: { post },
    tags: [`post:${post.id}`, `author:${post.authorId}`],
  };
}

A tag is a string of 1-256 bytes without control characters, and well-formed Unicode (an emoji cut in half by title.slice(0, 20) is not); a render keeps at most 64 (duplicates are dropped), and tags starting with _gio: are reserved. Invalid tags are ignored with a warning in the log rather than failing the render. Tags only matter on pages that are cached (revalidate set). Every cached page is also purgeable by its path - no tag needed.

revalidateTag() and revalidatePath()

Call them from server code - route handlers, getServerSideProps, anything the worker runs - after the data changed:

app/api/posts/[id]/route.ts
import { revalidatePath, revalidateTag } from '@gio.js/core';

export async function PUT(req) {
  await db.posts.update(req.params.id, req.json());
  await revalidateTag(`post:${req.params.id}`);     // pages tagged post:<id>
  await revalidatePath('/');                           // the home page
  await revalidatePath('/blog', { type: 'prefix' });   // /blog and everything below it
  return { ok: true };
}
  • revalidateTag(tag) purges every cached page carrying the tag.
  • revalidatePath(path) purges the page at that URL path - all of its query strings and locales. With { type: 'prefix' } it purges the path and everything below it, at segment boundaries (/blog covers /blog/a, not /blogger). A query or fragment in the path is ignored, and so is a leading locale segment (/fr/about purges /about in every locale - pages are cached under their locale-free path, so a bare /fr with { type: 'prefix' } purges every page of the site, and the server logs a warning when it does). The path is the one the page renders at - after any [[rewrites]].
  • Paths may be given decoded or percent-encoded: /blog/café and /blog/caf%C3%A9 (or /blog/a b and /blog/a%20b) purge the same page, so a slug straight from your CMS or database works. A % always starts an escape - write a literal % as %25.

Both resolve once the server confirmed the purge, with { ok: true, purged } (the number of cache entries removed - each query-string and locale variant counts). If the confirmation does not arrive within 5 seconds, or the server connection drops, they resolve with ok: false and an error, and log a warning - they do not throw, so a write that already succeeded is not failed over its cache refresh. To purge a batch, call them in parallel - await Promise.all(ids.map(id => revalidateTag(`post:${id}`))): the worker sends at most 16 at a time and the rest wait their turn, within the same 5 seconds. An invalid tag or path - an unpaired surrogate, a . or .. segment, a % that does not start an escape, a path under /_gio - is a programming error and rejects with a TypeError. Under gio export (and in unit tests) there is no cache to purge: they do nothing and warn once.

A render that was already running when the purge happened (a cache miss or a background refresh) still answers the requests that were waiting for it, but its result is not cached - it may have read the old data - and a request that arrives after the purge renders on its own instead of joining it, so the purge always wins.

From outside: POST /_gio/revalidate

External systems - a CMS webhook, a deploy script - purge through an HTTP endpoint. It exists only when you configure a token of at least 32 bytes, in the environment (wins) or in gio.toml; without one, /_gio/revalidate is a 404:

bash
# generate a token
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
export GIO_REVALIDATE_TOKEN=<token>     # or [revalidate] token = "..." in gio.toml
bash
curl -X POST https://example.com/_gio/revalidate \
  -H "Authorization: Bearer $GIO_REVALIDATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "tags": ["post:42"], "paths": ["/blog"], "prefix": true }'
# {"ok":true,"purged":7}

The JSON body takes tags and/or paths (up to 64 each, same rules as above) and prefix (true purges every path as a prefix). The answer is 200 with the number of purged entries, 400 for a malformed body (unknown fields included, so a misspelled tag is an error, not a silent no-op), and 401 for a missing or wrong token. The token is compared in constant time, and a client that sends 10 wrong tokens within a minute gets 429 for the rest of that minute - even with the right token (behind a reverse proxy, list it in [server] trusted_proxies so clients are told apart by their own address, not the proxy's). The endpoint is authenticated by its bearer token, not by cookies, so the cross-site request checks of [security.csrf] do not apply to it; call it over HTTPS.

The cache belongs to each server instance - there is no shared cache backend yet. With several instances behind a load balancer, call the endpoint on every instance (by its own address, not through the balancer); revalidateTag() and revalidatePath() only purge the instance whose worker runs them. Instances that share a disk cache directory ([cache] disk_path or GIO_CACHE_DIR) serve the pages each other stored, but each keeps its own memory cache - purge every one of them all the same.

Personalized pages are never shared

A cached page is served to everyone, so it must not depend on who is asking. When getServerSideProps reads the visitor's credentials - any access to ctx.cookies, ctx.ip, ctx.host or ctx.scheme, or reading the cookie, authorization, a client-address (x-forwarded-for, forwarded, x-real-ip) or a host (host, x-forwarded-host, x-forwarded-proto) entry of ctx.headers (including spreading or enumerating the headers) - GioJS marks that render personalized and does not cache it, even though the page exports revalidate. A warning is logged once per route. Headers an onRequest plugin added, changed or removed count as credentials too; other headers (accept-language, user-agent, ...) do not. A plugin that reads the credential headers and then rewrites the request's path, query or locale personalizes the render too: Rust keys the cache on the URL as requested, so that render (say, an admin dashboard picked from a role cookie) is never stored under it.

To keep a personalized page fast, either drop revalidate (it renders per request, streamed) or cache the shared part with partial prerendering: shell = 'cache' plus <Suspense> holes for the personalized parts.

Browser and CDN caching

Page responses tell browsers and CDNs the same thing the Rust cache knows. A page cached for everyone gets:

bash
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=540
ETag: W/"4f1c0a9be27d63e5d1b8a04c9f2e7a13"
  • s-maxage - what is left of the page's revalidate window, so a CDN in front of GioJS caches it no longer than GioJS does
  • stale-while-revalidate - the rest of the window in which GioJS itself serves the page stale while it refreshes (nine times revalidate by default: a page stays servable until it is [cache] swr_multiplier times revalidate old, 10 unless set; 0 never serves stale and leaves the directive out)
  • max-age=0 - browsers revalidate every time; with the ETag that costs a 304 Not Modified without a body while the page is unchanged

An on-demand purge (above) reaches browsers at once: the re-rendered page has a new ETag, so their next revalidation gets a 200 instead of a 304. It does not reach a CDN, which may serve its copy for the rest of s-maxage. Purge the CDN from the same webhook, or keep revalidate short on pages you purge on demand.

The ETag is a hash of the stored page, computed once when it is cached. It is weak (W/): the same page goes out gzip, brotli or uncompressed under it, and a strong ETag would have to differ between those encodings. A request whose If-None-Match names it gets a 304 with the same headers (X-Request-Id, security headers, header rules and the Vary: accept-encoding of a compressed page included). [cache] etag = false sends no page ETags and no 304s. The dev server sends none either: it inlines your current CSS into every response, so a stylesheet edit always reaches the browser. Pages rendered per visitor - personalized, uncached, streamed, every PPR response (its holes are personal) and error pages - get Cache-Control: private, no-cache: no shared cache stores them, and browsers still keep the back/forward cache (no-store would disable it).

A Cache-Control you set yourself always wins - from getServerSideProps headers, a route handler's Response, or a [[headers]] rule. Route handler responses get no default at all, whatever their content type and whether or not their body streams - only an event stream gets no-cache (see Route Handlers). In these cases GioJS never makes a page public or sends an ETag, because one URL is not the same page for everyone who asks:

  • A page behind a guard ([[guards]] in gio.toml or guards in middleware.ts), and any page requested with an Authorization header, is private, no-cache. A CDN keys by URL and never runs the guard: storing the page for an admitted visitor would serve it to everyone the guard turns away.
  • With [i18n] detecting the locale from accept-language or a cookie, an unprefixed URL is private, no-cache (locale-prefixed URLs like /de/about stay public).
  • With CSP nonces ({nonce} in [security] csp) every response is unique, so even cached pages are private, no-cache - a CDN replaying one would hand every visitor the same nonce.

GioJS still caches all of these pages itself (its guards run before its cache) and fills in a fresh nonce per response.