stoneware

Documentation

Caching

What every response tells a browser and a CDN, why a page is never cached on the server, and the header that keeps a shared cache from handing one visitor another's page.

Stoneware caches nothing on the server. No rendered HTML, no route output, no data layer. What it does instead is describe each response accurately enough that the browser and the CDN in front of it can do the caching, which is where caching is cheap and where invalidating it is somebody else's already-solved problem.

That is a deliberate choice with numbers behind it, and the second half of this page is the argument for it. The first half is what the headers actually say.

What each response says

  page, ordinary          public, no-cache
                          ETag: W/"<hash of the html>"
                          Vary: Cookie, Authorization

  page, CSRF token        private, no-store
                          no ETag

  page, development       no-store

  404 / 500               no-store

  /_stoneware/*.js|css    public, max-age=31536000, immutable
                          (content-hashed filename)

  public/ files           no-cache
                          ETag: W/"<size>-<mtime>"
                          Last-Modified
every response Stoneware produces
no-cache does not mean "do not store". It means "revalidate before use". A browser or CDN keeps the bytes and a repeat visit costs one conditional request, which is answered with an empty 304 when nothing has changed.

Pages revalidate, they do not expire

An ordinary page carries a weak ETag derived from the rendered HTML, so the validator changes exactly when the page does. There is no max-age to tune and no window during which a visitor sees yesterday's article: publish a change, and the next conditional request gets a different hash and a 200.

The honest cost of that is worth stating. Because the validator is a hash of the output, producing it means producing the output — so a request that ends in 304 has still run the route, rendered the tree and assembled the document. Measured on a 14 KB page, a 304 costs about four fifths of what the 200 costs. The saving is real but it is bandwidth, not server work.

  page   200   0.098ms      304   0.086ms     saves ~12%
  asset  200   0.239ms      304   0.011ms     saves ~95%
measured in process, no sockets

The contrast is the point. A file's validator is its size and mtime, which are known without reading it, so an asset 304 is around twenty times cheaper than sending the file. A page's validator is the page.

What a shared cache is told to key on

Every cacheable page carries Vary: Cookie, Authorization. Without it a shared cache keys on the URL alone, and any route that reads a session cookie becomes a way to serve one visitor another visitor's page.

That is not hypothetical. Measured against a cache keyed on the URL that serves what it holds — the "cache everything" configuration every CDN offers — with Alice arriving first and two more visitors arriving together:

  without Vary                    with Vary
  ────────────────────────        ────────────────────────
  alice     -> alice              alice     -> alice
  bob       -> alice              bob       -> bob
  carol     -> alice              carol     -> carol
  anonymous -> alice              anonymous -> guest

  three of three visitors         each visitor served
  served another user's page      their own page
same route, same cache, with and without the header

The header is sent on every page, not only on the ones that read a cookie. Vary describes the resource rather than the request: a response cached from a visitor who sent no cookie would otherwise be handed to one who did, which is the same failure arriving a step later.

It costs something, and the cost is real. A site whose visitors carry a per-user analytics cookie gets a distinct cache key each, so shared-cache reuse for those visitors drops toward nothing. Cookie-less visitors — the actual public case — still share one entry. Correctness first; the alternative is a page that is only conditionally the right person's.

Static assets deliberately do not carry it. They are bytes from disk, identical for every visitor, and fragmenting a CDN's key for them would cost reuse and buy nothing.

Pages that belong to one visitor

Rendering a CSRF token — through <Form> or csrfToken() — marks the response as belonging to one person. It becomes private, no-store with no ETag at all, because a fresh token per render means the body genuinely changes every time and there is nothing to revalidate against.

That flag means "this render issued a token". It does not mean "this page is the same for everyone", and it never did — which is exactly why Vary is sent unconditionally rather than only when the flag is unset.

If a route personalizes from something else — a session cookie, an Authorization header — and you want it kept out of shared caches entirely rather than merely keyed correctly, say so in the route by returning your own response headers from a server action, or put the page behind a path your CDN is configured not to store.

Static assets

Two kinds, with two different strategies, and the difference is whether the filename carries a content hash.

  • Built output — island chunks and the bundled stylesheet — is content-hashed, so the bytes can never change under a name. Cached for a year and marked immutable: no revalidation, no purge, and a deploy takes effect immediately because the page asks for a different filename.
  • Files in public/ are served byte-for-byte at their own URL and are not hashed, so they revalidate. The validator is derived from the file's size and mtime, read for each response, and Last-Modified is sent alongside it so a client can use either.

The resolved path of a file in public/ is remembered after it is first served, because resolving one is the expensive half — the symlink check alone costs about ten times what reading size and mtime does. The validator is deliberately not remembered: an earlier version cached it, and a file replaced under a running server then kept its old ETag, so a client holding it revalidated to 304 for as long as the process lived. Correct bytes, stale validator, and permanent from the client's side.

The set of files in public/ is read once at startup in production, so a file added while the server runs is not served until it restarts. Contents changing is handled; files appearing is not. Development re-reads on every request.

Why there is no server-side page cache

The obvious next step is to keep rendered HTML in memory and skip the render. It was measured before it was rejected, and the numbers did not support it.

  renderToString, 14 KB page      0.021ms
  buildDocument                   0.002ms
  whole framework request path    0.122ms
  the same request over HTTP      ~0.9ms

  rendering is ~2% of the request
where the time in a page request actually goes

A perfect HTML cache would remove the render and nothing else — roughly seven per cent of an end-to-end request. In exchange it would bring cache invalidation, memory growth, and a coherence problem across worker processes, each of which is a source of the exact bug people report against frameworks that do have one: a page that will not update.

Nothing is cached, so nothing goes stale. An article edited and redeployed is correct on the next request, with no purge step, no revalidate call, and no tag to remember to invalidate.

It also means the multi-process mode has nothing to keep in sync. Four workers hold four copies of the route table and the island manifest, all identical and all read-only. Next.js needed a pluggable cache handler precisely because its per-instance cache is not coherent across instances; there is no equivalent problem here because there is no equivalent cache.

What your CDN needs from you

  • Forward the Cookie and Authorization headers to the origin. A CDN that strips them cannot personalize anything — the origin never sees them, and no response header can repair that.
  • Honour Vary. If the cache keys only on the URL, it will serve one visitor's page to another, and that is a configuration choice rather than something the origin can prevent.
  • Do not add a max-age to HTML. The pages revalidate on purpose; a max-age is a window during which a published change is invisible.
  • Leave /_stoneware/* alone. It is already immutable and hashed, and it is the one thing on the site that genuinely can be cached forever.

An exported site has no server to revalidate against, so its caching is entirely the host's. See static export for what the export writes and what a host has to be told.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.