[]previous and next page

Caching pages (ISR)

A page that exports revalidate is rendered once and then served from a cache for that many seconds.

For agentsUpdated

A page that exports revalidate is rendered once and then served from a cache for that many seconds. Only the answers of the page are cached (the document, not the middleware's headers), and only when the request is one a shared cache may serve. The options are literals, read from the source like ssr:

Export Effect
export const revalidate = 60 Fresh for 60 seconds. After that the answer is stale
export const revalidate = false Kept until it is purged (revalidatePath, revalidateTag) or evicted
export const cache = { staleSeconds: 300, cookies: ["locale"] } A stale answer may be served for 300 more seconds while one background render refreshes it. cookies names the cookies the page depends on: they are part of the key
src/routes/news.tsts
export const revalidate = 60;
export const cache = { staleSeconds: 300, cookies: ["locale"] };

The rules, in order:

  • Cacheable: a GET or HEAD answer that is 200, has no Set-Cookie, and was made for a request with no Authorization header and no cookie the page does not declare. Anything else is answered as it would be without the cache and marked x-dusk-cache: BYPASS.
  • The key is the page, nothing else. An entry is found by host, path, the query parameters the route names in cache.searchParams (all of them when it names none) and the values of the cookies it declares. The path is the one the router reads: /About, /about and /%61bout are one entry, and revalidatePath("/about") removes it whichever spelling filled it. A render sees only the keyed part of the request: a query parameter outside searchParams is not in event.url when the page is rendered for the cache.
  • A header the page reads must be in Vary. The render for the cache records the request headers the page reads (event.request.headers.get(...)). A page that read a header its Vary does not name (a locale from Accept-Language, a user from an auth proxy's X-Forwarded-User) is answered for that request and not stored, because the key could not tell two visitors of that header apart; development prints a warning that names the page and the header. Set event.responseHeaders.set("vary", "accept-language") in the loader to store one entry per value instead. cookie is not counted: the key already holds the cookies the page declares.
  • Errors are never stored. A 404, a 500, a redirect: the next request renders again.
  • Stale-while-revalidate: a fresh answer is HIT; a stale one within its stale window is STALE, and one background render refreshes it while the request is answered at once (through the platform's waitUntil when it has one); a refresh that fails keeps the stale page, is reported once and backs off; after the window the request waits for a MISS. Concurrent misses for one key share one render, at most 1 000 requests per key. The configuration guide, Scaling the caches, has the bounds, the headers and what several instances share.
  • Headers: Cache-Control: public, max-age=0, s-maxage=<lifetime>, stale-while-revalidate=<stale seconds> with an Age (a shared cache counts Age against s-maxage, so it expires the page when the server does), an ETag (a 304 for If-None-Match), and Vary: cookie when cookies are part of the key. A page with a CSP nonce gets the nonce of each response: the cached HTML is rewritten to it.
  • Prerendering: dusk build renders prerendered pages with a fresh render, which becomes the static file. A static host does not run the handler, so a prerendered page is not revalidated at run time; use revalidate on server-rendered pages.

Purge from a server function or a loader. A purge takes effect for the next request (no stale answer after it):

src/server/posts.tsts
import { cacheTags, revalidatePath, revalidateTag, serverFn } from "@shadow-js/dusk/server";

export const savePost = serverFn({}, async (input) => {
  // Save the post, then discard the pages that show it.
  const id = typeof input === "object" && input !== null && "id" in input ? String(input.id) : "";
  await revalidatePath(`/posts/${id}`);
  await revalidateTag("posts");
  return { id };
});

// In a loader of a cached page: tag the answer, so `revalidateTag("posts")` discards it.
export function postLoader(id: string): string {
  cacheTags("posts", `post:${id}`);
  return id;
}

The cache lives in the process by default: a bounded, least-recently-used store of 500 pages (createHandler({ cache: { maxEntries } })). Several instances behind a load balancer need a shared store. Implement the CacheStore interface (get, set, delete, tags, and optionally purgeTag), or pass any Redis-like client to redisStore from @shadow-js/dusk/stores/redis, which needs get, set with a millisecond expiry, del, sAdd, sRem and sMembers. It keeps one string per page with a version tag, a set per tag (no scans), and removes a tag's pages without losing the index of a write that overlaps the purge. Test a store of your own with runStoreContract from @shadow-js/dusk/stores/contract. The configuration guide's Scaling the caches shows the adapters for ioredis, node-redis and Upstash, and what the memory store and a shared store each guarantee across instances.

src/server/cache.tsts
import { type RedisClient, redisStore } from "@shadow-js/dusk/stores/redis";

export function sharedCache(client: RedisClient) {
  return redisStore(client, { prefix: "app:isr:" });
}

createKeyValueCacheStore is the smaller adapter over the same client (no version tag, no size cap, no purgeTag).