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: this site's demo route
{
path: '/demo/pokemon/{name}',
Component: DemoPokemonPage,
loader: demoPokemonLoader,
cache: { ttl_seconds: 60, tags: ['pokemon'], bypass: 'query(nocache)' },
}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.
- 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:
$ 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-cache | Meaning |
|---|---|
MISS | Not in the cache: the loader ran, the page was rendered and stored. |
HIT | Served from memory. No loader, no render, no Bun. |
BYPASS | The bypass expression matched, or the request was ambiguous (a repeated query name). Neither read nor stored. |
HIT-L2 | Not 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=2and?b=2&a=1share an entry; a different query is a different entry. - GET and HEAD share entries. Other methods are never cached.
- Only a 200 without
Set-Cookieis stored, so anotFound()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.
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
| Expression | What 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 word | That 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.
cache: {
ttl_seconds: 60,
prefix: "lower(or(query(lang), cookie(lang), 'en'))",
}$ 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.
cache: {
ttl_seconds: 60,
prefix: "concat(request(host), '|', lower(or(cookie(lang), 'en')))",
}$ 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.
cache: {
ttl_seconds: 60,
prefix: "concat('ab:', or(eq(cookie(ab), 'b', 'b'), 'a'))",
}$ 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.
cache: {
ttl_seconds: 60,
bypass: "cookie(session)",
prefix: "or(eq(cookie(plan), 'pro', 'pro'), 'free')",
}$ 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.
cache: {
ttl_seconds: 60,
prefix: "or(eq(header(sec-ch-ua-mobile), '?1', 'mobile'), 'desktop')",
}$ 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).
cache: {
ttl_seconds: 60,
prefix: "concat(env(BUILD_ID), ':', param(name))",
}$ 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
- An unbounded value makes one entry per value. A raw
accept-language,user-agentor user id in a prefix fills the cache with near-duplicates. Fold it to a small set witheqandor, 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.
cache: {
ttl_seconds: 60,
bypass: "eq(query(mode), 'draft', 'yes')",
}$ 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.
cache: {
ttl_seconds: 60,
prefix: "lower(or(query(lang), cookie(lang), 'en'))",
}$ 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.
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: 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 defaulttags 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.