Cache adapters
A cache adapter adds an optional shared second tier behind brust’s in-process page cache.
It does two jobs:
- Store. It keeps rendered pages where every process or isolate can read them.
- Broadcast. It tells every other instance when
cache.invalidate(...)ran, so they drop the stale entries.
An adapter is a plain TypeScript object. brust ships adapters for Redis and Dragonfly and for Cloudflare KV and Durable Objects, and you can write your own. With no [cache] table in brust.toml, nothing here is active: behaviour and bytes are the same as without this feature.
The two tiers
request -> tier 1: in-process cache (microseconds) --hit--> 200, x-brust-cache: HIT
| miss (cacheable route, no bypass, no Set-Cookie)
v
tier 2: adapter store.get(key) --hit--> copied into tier 1, 200, x-brust-cache: HIT-L2
| miss, timeout or error
v
loader -> jobs -> render -> tier 1 insert -> tier 2 store.set (in the background)- Tier 1 is always there: the Rust cache of the process (native) or of the isolate (Workers). The adapter is never called on a tier-1 hit.
- Tier 2 exists only with an adapter that has a
store. Only rendered page bodies are shared. Job results stay in tier 1. Akeyinvalidation (job user keys) is broadcast, but the store ignores it. - A
cache.invalidate(...)call clears tier 1 of the process that ran it at once. The adapter then deletes the shared entries and broadcasts the event. Other instances replay it into their own tier 1.
When to use one
Use an adapter when:
- you run several brust processes or hosts and a mutation must show up on all of them quickly;
- you run on Cloudflare Workers, where every isolate has its own tier 1 and
cache.invalidateis isolate-local (see Cache on KV and DO); - renders are expensive and you want a warm shared cache after a restart.
Do not use one for a single native process: tier 1 already does the job, and a miss then pays an extra round trip.
An adapter never makes a request fail. A get that errors or exceeds get_timeout_ms counts as a tier-2 miss and the page renders normally.
Configuration
[cache]
adapter = "custom" # redis | dragonfly | cloudflare-kv | cloudflare-do | custom
module = "lib/cache.ts" # custom only: a file inside the app
broadcast = "cloudflare-do" # optional: take the broadcast from another built-in adapter
namespace = "my-app" # default: package.json "name", else "brust"
get_timeout_ms = 50 # 1..1000; default 50 on native, 100 on Workers
max_body_bytes = 1048576 # 1..67108864; larger pages are not written to tier 2
epoch_interval_ms = 1000 # 250..60000; pull broadcasts only (Workers)
[cache.options] # passed to a custom adapter's factory| Key | Default | Meaning |
|---|---|---|
adapter | none (no tier 2) | One of the five names. Required when [cache] exists. |
module | custom only. The default export is a CacheAdapter or a factory (options) => CacheAdapter. A built-in adapter with module is a build error. | |
broadcast | A built-in name. The store comes from adapter, the broadcast from this one (for example KV store plus Durable Object broadcast). | |
namespace | package name | 1..64 characters from A-Za-z0-9@._/-. Shared entries are scoped by it. |
get_timeout_ms | 50 / 100 | The most a request waits for tier 2. |
max_body_bytes | 1048576 | Bodies above it are not written. |
epoch_interval_ms | 1000 | How often an isolate asks a pull broadcast for new events, at most. Lower means fresher and costlier (see the cost tables on the Cloudflare page). |
[cache.redis], [cache.cloudflare] | Options of the built-ins: Redis, Cloudflare. | |
[cache.options] | Options of a custom adapter. |
Rules the build enforces:
- Unknown keys are an error.
- Keys of every shared entry include a build id, a hash of your manifest, templates,
jobs.jsandactions.js. A deploy therefore starts with an empty tier 2; old entries expire by their TTL. Plan for a cold cache after each deploy. - A built-in adapter is an opt-in package. If it is not installed the build says so, for example
adapter "redis" needs @brust/cache-redis (bun add @brust/cache-redis). env:NAMEvalues in a built-in’s option table are read from the process environment at start (native). A missing variable stops the start and names the variable, never its value. On--target cloudflare, anenv:value is a build error: use a binding. Options of acustomadapter are passed as written; read the environment yourself.- Native accepts push broadcasts only. Workers accept pull broadcasts only.
Runtimes
Native (brust start) | Workers | |
|---|---|---|
| Where the adapter runs | in every Bun worker | in the Worker (bundled into brust-app.js) |
| Broadcast received by | worker 0 of each process | each isolate, by polling |
| Write to tier 2 | background; dropped when no worker is free | waitUntil, after the response |
Semantics per backend
What each backend gives:
| Redis / Dragonfly | KV alone | Durable Object | KV + Durable Object | Custom | |
|---|---|---|---|---|---|
| Runs on | Bun | Workers | Workers | Workers | what runtimes says |
| Store | shared bodies, expire by TTL | shared bodies, long-TTL only | optional (do_store), 16 shards | KV bodies | optional |
| Stale-write guard | exact | none: a stale body can live up to its TTL | exact | floors in each isolate | whatever you implement |
| Broadcast | push, immediate | none | pull, ordered ring | pull, ordered ring | push or pull |
Other instances after cache.invalidate | about pub/sub latency | up to the TTL | within about epoch_interval_ms plus one round trip | same | depends |
| On failure | fail-open; a gap clears tier 1 | fail-open | fail-open; a restart clears tier 1 once | fail-open |
KV is a long-TTL store, not an invalidation channel. KV writes are visible in other locations “up to 60 seconds” later (source C9 on the Cloudflare page), and KV has no compare-and-set, so brust cannot refuse a stale write. The KV adapter therefore refuses to store pages with a TTL below 300 seconds by default, and the docs advise pairing it with the Durable Object broadcast (broadcast = "cloudflare-do"). Alone, a deleted page can still be read for a while from other locations.
A lost invalidation (publish failed, or the broadcast was down) leaves other instances stale for at most the page’s TTL. Gaps the adapter can detect clear tier 1 instead. One blind spot on native: if the last event is lost and nothing follows it, no gap is seen until the next event, so the bound is the TTL.
Failure behaviour and stats
Everything on the request path has a budget and fails open:
| Operation | On the request path? | Budget | On failure |
|---|---|---|---|
| tier-1 probe | yes | microseconds | none |
store.get (tier-1 miss) | yes | get_timeout_ms | tier-2 miss; no write follows |
pull poll (since) | Workers: the first request after epoch_interval_ms, one at a time | 500 ms | serve tier 1; poll_errors +1 |
store.set | no | native: dropped if no worker is free; Workers: waitUntil | counted, never an error to the client |
store.invalidate and publish | no | publish_errors +1; others stale up to the TTL |
A tier-2 hit answers with x-brust-cache: HIT-L2; a tier-1 hit stays HIT. A stored page’s TTL is what is left of its original TTL, so a page never lives longer in total than its route’s ttl_seconds.
With an adapter configured, /_brust/cache/stats gains an l2 object. Without one, the JSON is unchanged. On Workers the path needs [cloudflare] stats = true, and the numbers are per isolate.
"l2": { "adapter": "custom", "hits": 0, "misses": 0, "timeouts": 0, "errors": 0, "rejected_floor": 0,
"sets": 0, "set_refused": 0, "set_dropped": 0, "set_oversize": 0, "published": 0, "publish_errors": 0,
"received": 0, "skipped_own": 0, "resets": 0, "epoch_polls": 0, "poll_errors": 0 }| Counter | Meaning |
|---|---|
hits, misses | tier-2 lookups that found or did not find a valid page |
timeouts, errors | lookups that exceeded get_timeout_ms, or failed |
rejected_floor | pages found but discarded because an invalidation newer than the page was already seen (Workers) |
sets, set_refused, set_dropped, set_oversize | writes done, refused by the store’s guard, dropped for lack of a free worker, skipped for size |
published, publish_errors | invalidations broadcast, and broadcasts that failed |
received, skipped_own | events replayed into tier 1, and events skipped because this instance sent them |
resets | times tier 1 was cleared because of a gap, a new gen, or falling behind |
epoch_polls, poll_errors | pull polls made and failed |
What v1 does not do
- No stampede protection. When a page expires, every instance that misses renders it. There is no cross-instance lock or render coalescing. A process-local single-flight is a possible follow-up.
- No entry signing. The store is trusted. Entries are scoped by namespace and build id only. If other parties can write to your Redis, they can put pages into your cache. Restrict access to it.
- No sharing of job results. Only page bodies are shared.
- No streaming bodies.
- No Redis on Workers. There is no TCP client a Worker can rely on. Use a custom adapter over an HTTP-based service.
- No KV or Durable Object adapter on native.