Caching pages (ISR)
A page that exports revalidate is rendered once and then served from a cache for that many seconds.
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 |
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 noSet-Cookie, and was made for a request with noAuthorizationheader and no cookie the page does not declare. Anything else is answered as it would be without the cache and markedx-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,/aboutand/%61boutare one entry, andrevalidatePath("/about")removes it whichever spelling filled it. A render sees only the keyed part of the request: a query parameter outsidesearchParamsis not inevent.urlwhen 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 itsVarydoes not name (a locale fromAccept-Language, a user from an auth proxy'sX-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. Setevent.responseHeaders.set("vary", "accept-language")in the loader to store one entry per value instead.cookieis 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 isSTALE, and one background render refreshes it while the request is answered at once (through the platform'swaitUntilwhen it has one); a refresh that fails keeps the stale page, is reported once and backs off; after the window the request waits for aMISS. 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 anAge(a shared cache countsAgeagainsts-maxage, so it expires the page when the server does), anETag(a 304 forIf-None-Match), andVary: cookiewhen 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 buildrenders 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; userevalidateon server-rendered pages.
Purge from a server function or a loader. A purge takes effect for the next request (no stale answer after it):
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.
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).