brust 2.0.0-alpha.5
Guide / Caching

Caching

A route with a cache field is stored in the Rust server's memory after its first render. A hit is answered without calling Bun.

Cache a route

routes.tsx
// routes.tsx: this site's demo route
{
  path: '/demo/pokemon/{name}',
  Component: DemoPokemonPage,
  loader: demoPokemonLoader,
  cache: { ttl_seconds: 60, tags: ['pokemon'], bypass: 'query(nocache)' },
}
@brust/core/routes
interface RouteCacheConfig {
  ttl_seconds: number        // required, 0 or more
  tags?: string[]            // names to invalidate by
  bypass?: true | string     // true, or an expression: skip the cache when it is non-empty
  prefix?: string            // an expression added to the key
}

bypass and prefix are small expressions over the request, such as query(nocache) or cookie(session). The whole grammar, with examples that split the cache, is in Split the cache with prefix. A layout route cannot have a cache: put it on the leaf.

Watch it work

The button requests /demo/pokemon/{name} three times with a fresh query string: the same URL twice, then with ?nocache=1. This site is built to ship as static files, so the requests go to a simulation of the route's rules in your browser, and each row shows the x-brust-cache header a brust server sends for it.

Simulated demoCacheProbe · native
  1. Press the button: same URL twice, then once with ?nocache=1.

Simulated in your browser: brust start sends these headers for real.

The same three requests against this site on brust start, recorded:

~/brust/website
$ curl -sI 'http://127.0.0.1:53577/demo/pokemon/eevee?v=docs'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-brust-cache: MISS
content-length: 4453
x-powered-by: brust/2.0.0-alpha.4
date: Tue, 13 Oct 2026 15:20:02 GMT

$ curl -sI 'http://127.0.0.1:53577/demo/pokemon/eevee?v=docs'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-brust-cache: HIT
content-length: 4453
x-powered-by: brust/2.0.0-alpha.4
date: Tue, 13 Oct 2026 15:20:02 GMT

$ curl -sI 'http://127.0.0.1:53577/demo/pokemon/eevee?v=docs&nocache=1'
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
x-brust-cache: BYPASS
content-length: 4453
x-powered-by: brust/2.0.0-alpha.4
date: Tue, 13 Oct 2026 15:20:02 GMT
x-brust-cacheMeaning
MISSNot in the cache: the loader ran, the page was rendered and stored.
HITServed from memory. No loader, no render, no Bun.
BYPASSThe bypass expression matched, or the request was ambiguous (a repeated query name). Neither read nor stored.
HIT-L2Not in this instance, found in the shared cache adapter.

A route without a cache field sends no header.

What is stored, under which key

  • The key is the prefix, the method, the path and the sorted query string. ?a=1&b=2 and ?b=2&a=1 share an entry; a different query is a different entry.
  • GET and HEAD share entries. Other methods are never cached.
  • Only a 200 without Set-Cookie is stored, so a notFound() page is rendered every time.
  • Cookies do not bypass by themselves. A page that paints per-user data must not be cached, or must use a bypass or prefix on that cookie.
Note

Expressions see the query and the route params decoded (%61 is a, + is a space), and header and cookie values exactly as sent. The cache key itself keeps the query as it was spelled.

Split the cache with prefix

By default every request for a route shares one entry per path and query. A prefix is an expression evaluated on every request and put in front of the key, so the same URL can have several entries: the key is the prefix, the method, the path and the sorted query. An empty prefix is valid, and means the shared entry.

The expressions

ExpressionWhat it returns
header(name)The request header, as sent, or an empty string. The name is matched ignoring case.
cookie(name)The cookie of that name, as sent (not decoded), or an empty string.
query(name)The query value, percent-decoded (+ is a space), or an empty string.
param(name)The route parameter, decoded: /pokemon/{name} gives param(name).
request(host), request(method), request(scheme), request(path)The Host header; GET (HEAD shares the GET entries); http or https; the path without the query string.
env(NAME)The process environment variable, read once, when the route is installed (empty if unset). Refused when building for Cloudflare Workers, which has no process environment.
'text', "text", or a bare wordThat text. A bare word is a literal too, so header(Authorization) works unquoted; a quoted string has no escapes.
or(a, b, …)The first argument that is not empty, or an empty string.
and(a, b, …)Every argument joined with the unit separator (U+001F) when none is empty, otherwise an empty string.
concat(a, b, …)The arguments joined, with nothing between them.
eq(a, b)a when it equals b, otherwise an empty string.
eq(a, b, v)v when a equals b, otherwise an empty string.
lower(a), upper(a)ASCII case folding. Letters outside A to Z are left alone.
uuid(), timestamp()Not allowed: the build stops, because a key must be the same for the same request.

For bypass the rule is the other way round: a non-empty result means skip the cache for that request. A wrong expression is a build error that names the problem.

Six examples, run for real

Each example is a route cache block from the site's test fixture, served by brust start, and requested with curl. The transcripts are what the server printed: BASE is the address it listened on, the fixture renders how many times its loader has run, and the server was started with BUILD_ID=r1. A test replays them against a fresh server and fails if they stop matching.

1. One entry per language

The language comes from the query, then the cookie, then a default; lower() folds TH and th into one entry, and a visitor with no cookie shares the English entry with one who sent lang=en.

routes.tsx · /ex/lang
cache: {
  ttl_seconds: 60,
  prefix: "lower(or(query(lang), cookie(lang), 'en'))",
}
recorded from brust start
$ curl -si "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>lang render 1</p>
$ curl -si "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>lang render 1</p>
$ curl -si -H 'Cookie: lang=th' "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>lang render 2</p>
$ curl -si -H 'Cookie: lang=TH' "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>lang render 2</p>
$ curl -si -H 'Cookie: lang=en' "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>lang render 1</p>

2. One entry per tenant and language

One brust server behind several hostnames: the host is part of the prefix, so tenant a never serves tenant b a page, and the language still splits each tenant.

routes.tsx · /ex/tenant
cache: {
  ttl_seconds: 60,
  prefix: "concat(request(host), '|', lower(or(cookie(lang), 'en')))",
}
recorded from brust start
$ curl -si -H 'Host: a.example.com' "$BASE/ex/tenant" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>tenant render 1</p>
$ curl -si -H 'Host: a.example.com' "$BASE/ex/tenant" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>tenant render 1</p>
$ curl -si -H 'Host: b.example.com' "$BASE/ex/tenant" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>tenant render 2</p>
$ curl -si -H 'Cookie: lang=EN' -H 'Host: a.example.com' "$BASE/ex/tenant" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>tenant render 1</p>
$ curl -si -H 'Cookie: lang=th' -H 'Host: a.example.com' "$BASE/ex/tenant" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>tenant render 3</p>

3. A bounded A/B bucket

Only the cookie value b gets its own entry; any other value, including junk, lands in bucket a, so the cache holds two entries however many values arrive.

routes.tsx · /ex/ab
cache: {
  ttl_seconds: 60,
  prefix: "concat('ab:', or(eq(cookie(ab), 'b', 'b'), 'a'))",
}
recorded from brust start
$ curl -si "$BASE/ex/ab" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>ab render 1</p>
$ curl -si -H 'Cookie: ab=b' "$BASE/ex/ab" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>ab render 2</p>
$ curl -si -H 'Cookie: ab=b' "$BASE/ex/ab" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>ab render 2</p>
$ curl -si -H 'Cookie: ab=zzz' "$BASE/ex/ab" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>ab render 1</p>
$ curl -si -H 'Cookie: ab=a' "$BASE/ex/ab" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>ab render 1</p>

4. Plan tier, and never cache a signed-in page

Anyone with a session cookie skips the cache altogether (bypass); everyone else is cached per tier, pro or free, and an unknown plan value is free.

routes.tsx · /ex/plan
cache: {
  ttl_seconds: 60,
  bypass: "cookie(session)",
  prefix: "or(eq(cookie(plan), 'pro', 'pro'), 'free')",
}
recorded from brust start
$ curl -si "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>plan render 1</p>
$ curl -si "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>plan render 1</p>
$ curl -si -H 'Cookie: plan=pro' "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>plan render 2</p>
$ curl -si -H 'Cookie: plan=pro' "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>plan render 2</p>
$ curl -si -H 'Cookie: plan=enterprise' "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>plan render 1</p>
$ curl -si -H 'Cookie: session=s1' "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>plan render 3</p>
$ curl -si -H 'Cookie: session=s1; plan=pro' "$BASE/ex/plan" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>plan render 4</p>

5. Device class

Client hints send ?1 for mobile and ?0 for everything else; eq() maps the one value that matters to a name and everything else, including no header, to desktop.

routes.tsx · /ex/device
cache: {
  ttl_seconds: 60,
  prefix: "or(eq(header(sec-ch-ua-mobile), '?1', 'mobile'), 'desktop')",
}
recorded from brust start
$ curl -si "$BASE/ex/device" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>device render 1</p>
$ curl -si "$BASE/ex/device" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>device render 1</p>
$ curl -si -H 'sec-ch-ua-mobile: ?1' "$BASE/ex/device" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>device render 2</p>
$ curl -si -H 'sec-ch-ua-mobile: ?1' "$BASE/ex/device" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>device render 2</p>
$ curl -si -H 'sec-ch-ua-mobile: ?0' "$BASE/ex/device" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>device render 1</p>

6. Per deploy, per route param

env() is read once, when the route is installed: set BUILD_ID when you start the server and the key carries the deploy; param() adds the path parameter (already decoded).

routes.tsx · /ex/deploy/{name}
cache: {
  ttl_seconds: 60,
  prefix: "concat(env(BUILD_ID), ':', param(name))",
}
recorded from brust start
$ curl -si "$BASE/ex/deploy/pikachu" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>deploy pikachu render 1</p>
$ curl -si "$BASE/ex/deploy/pikachu" | grep -E 'x-brust-cache|render'
x-brust-cache: HIT
<p>deploy pikachu render 1</p>
$ curl -si "$BASE/ex/deploy/eevee" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>deploy eevee render 2</p>

Pitfalls

Pitfalls
  • An unbounded value makes one entry per value. A raw accept-language, user-agent or user id in a prefix fills the cache with near-duplicates. Fold it to a small set with eq and or, as the A/B and device examples do.
  • A page with per-user data needs bypass, not a prefix. A prefix keeps users apart only as long as the value never repeats between them; bypass: 'cookie(session)' keeps their pages out of the cache entirely.
  • Values are not all decoded the same way. The query and route params reach the expression decoded; headers and cookies as sent. An eq(cookie(x), '/') will not match %2F.
  • An ambiguous request is never cached. A query name sent twice, or two header, cookie or query names that differ only in case, answers BYPASS.

0. The query is decoded before an expression reads it

bypass: eq(query(mode), 'draft', 'yes'): an encoded name or value reaches the expression decoded.

routes.tsx · /ex/decode
cache: {
  ttl_seconds: 60,
  bypass: "eq(query(mode), 'draft', 'yes')",
}
recorded from brust start
$ curl -si "$BASE/ex/decode?mode=draft" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>decode render 1</p>
$ curl -si "$BASE/ex/decode?mode=dr%61ft" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>decode render 2</p>
$ curl -si "$BASE/ex/decode?mo%64e=draft" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>decode render 3</p>
$ curl -si "$BASE/ex/decode?mode=live" | grep -E 'x-brust-cache|render'
x-brust-cache: MISS
<p>decode render 4</p>

0. An ambiguous request is never cached

The language example again: a query name sent twice, or two cookies whose names differ only in case, is answered with BYPASS.

routes.tsx · /ex/lang
cache: {
  ttl_seconds: 60,
  prefix: "lower(or(query(lang), cookie(lang), 'en'))",
}
recorded from brust start
$ curl -si "$BASE/ex/lang?a=1&a=2" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>lang render 3</p>
$ curl -si -H 'Cookie: lang=th; Lang=en' "$BASE/ex/lang" | grep -E 'x-brust-cache|render'
x-brust-cache: BYPASS
<p>lang render 4</p>

Try the expressions

Pick an example or type your own, describe a request, and read off the prefix and the key parts. It is the same rules as the server, evaluated in your browser.

Live demoCacheKeyPlayground · react
prefix  "th"
method  GET
path    /ex/lang
query   ""

Invalidate

Call it from server code (a loader or an action) after the data changed:

actions.ts
// actions.ts: drop every page tagged 'team' after a real change
import { cache } from '@brust/core'

if (added) cache.invalidate({ tags: ['team'] })
cache.invalidate({ path: '/pokemon/pikachu' })   // every cached query of that path, GET by default

tags drops the tagged pages and the server results cached under those tags; path drops that path's pages. In the browser, cache.invalidate throws: it is server-only.

Across instances

The page cache lives in one server's memory. To share it between instances, and to send every invalidation to all of them, configure a cache adapter (Redis or Dragonfly, Cloudflare KV or Durable Objects, or your own) in [cache]. A hit from it is reported as HIT-L2. See Cache adapters.