brust 2.0.0-alpha.5
Cache adapters / Cloudflare

Cloudflare KV and Durable Objects

Workers only. KV holds the page bodies; a Durable Object tells every isolate what was invalidated.

~/my-app
$ bun add @brust/cache-cloudflare

Package @brust/cache-cloudflare. It imports nothing from Bun.

Three configurations

[cache]What it is
adapter = "cloudflare-kv"Pages in Workers KV. A store with no broadcast.
adapter = "cloudflare-do"A Durable Object that carries invalidations (a pull broadcast). With do_store = true it also stores the pages, in 16 shard objects.
adapter = "cloudflare-kv" and broadcast = "cloudflare-do"The recommended pair. KV holds the bodies and the Durable Object tells every isolate what was invalidated.
brust.toml
[cache]
adapter = "cloudflare-kv"
broadcast = "cloudflare-do"
epoch_interval_ms = 5000

[cache.cloudflare]
kv_min_ttl_s = 300

Bindings you declare

brust adds no binding. Declare them in your own wrangler.jsonc. The build checks the top level and every env.* block, and if something is missing it fails and prints this snippet:

wrangler.jsonc
{
  "kv_namespaces": [{ "binding": "BRUST_CACHE", "id": "<wrangler kv namespace create brust-cache>" }],
  "durable_objects": { "bindings": [{ "name": "BRUST_CACHE_DO", "class_name": "BrustCacheDO" }] },
  "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]
}

kv_namespaces is needed for KV and the pair. The durable_objects binding and the migration are needed for cloudflare-do and the pair. BrustCacheDO must be a SQLite-backed class (new_sqlite_classes, or an exports entry with "storage": "sqlite"). Create the KV namespace with wrangler kv namespace create.

When a Durable Object is configured, brust build also writes dist/cf/worker/main.js, a short generated file that re-exports the Worker and BrustCacheDO so Cloudflare can bind the class, and points main at it. Do not write your own. The build refuses if @brust/core-cf ever ships a worker/main.js itself.

Options

These go under [cache.cloudflare]. An unknown key in this table is an error that names it. env: values are not allowed on this target.

KeyUsed byDefaultMeaning
kv_bindingKV, pair"BRUST_CACHE"Name of the KV binding.
kv_min_ttl_sKV, pair3000..31536000. A route whose TTL is smaller is not written to KV (KV’s own floor is 60 s).
do_bindingDO, pair"BRUST_CACHE_DO"Name of the Durable Object binding; its class_name must be BrustCacheDO.
do_storecloudflare-dofalsetrue stores pages in 16 Durable Object shards. Ignored with the pair.
Caveat

[cache.cloudflare] is ignored for adapter = "custom". The build reads [cache.redis] and [cache.cloudflare] only for built-in adapters, and without a warning. A custom adapter that calls kvAdapter({ binding, min_ttl_s }), doAdapter({ binding, store }) or combine(kv, do) itself (all exported by @brust/cache-cloudflare) must pass its options through [cache.options] or in code, using the programmatic option names: binding, min_ttl_s, store.

Caveat

Migrations in env blocks. The build accepts a migrations or exports declaration of the class at the top level for an env.* block. It has not been verified that wrangler deploy --env <name> applies a top-level migrations entry to that environment. If you deploy an env, declare the bindings and the migration inside that env block, and try wrangler deploy --dry-run --env <name> first.

What each configuration guarantees

ConfigStoreOther isolates after cache.invalidateA stale page served again?Free-plan cost driver
cloudflare-kv aloneKV, unguardedup to the page TTL (no broadcast)yes, up to the TTL (at least 300 s by default)1 KV write per tier-1 miss
cloudflare-do, do_store = falsenone (tier 1 only)within epoch_interval_ms plus one round tripno1 DO request per poll per isolate
cloudflare-do, do_store = trueDO shards, exact write guardwithin epoch_interval_ms plus one round tripno (the rows are deleted)plus 1 DO request per tier-1 miss, about 18 per invalidation
KV + broadcast = "cloudflare-do"KV, guarded by per-isolate floorswithin epoch_interval_ms plus one round tripno, once the isolate knows the invalidation1 KV write per miss plus the DO polls

KV alone never invalidates. Tier-2 keys hash the route prefix, method, path and query, so KV cannot list the pages under a tag or a path without extra index writes. Use the pair unless a stale page for up to its TTL is acceptable.

How the pair stays correct. Each isolate keeps, for every tag and path, the sequence number of the last invalidation it knows. It seeds this from the Durable Object when it starts and after a restart, raises it from every event it polls, and raises it from its own invalidations. A KV page whose write stamp is older than the floor of its tags or path, or which has no stamp, is treated as a miss. A stale write from a lagging isolate can land in KV, but every isolate that knows the invalidation, including a new one, rejects it. The cost is an extra render, never a stale page.

Things that behave in ways you should expect

  • KV can hide a fresh page for up to 60 s per location. The adapter reads KV with a 60-second cacheTtl, and KV allows one write per second per key. After an invalidation, a hot page’s new value may stay invisible at a location’s KV cache for up to a minute. Each isolate there then misses (the old value is rejected by the floor) and renders the page itself, and its write may get a 429 (the adapter returns false). This is correct, but expect a burst of renders after invalidating a popular page.
  • Lagging stamps cost misses. Writes are stamped with the sequence number this isolate last saw, which can trail the Durable Object by up to epoch_interval_ms. A page rendered right after a remote invalidation may be written below another isolate’s floor and be rejected there: an extra miss, never a stale serve.
  • A Durable Object restart clears every isolate once. The Durable Object keeps its counters and floors in SQLite storage, so sequence numbers never go backward. Its event ring is in memory and it takes a new generation id each time it starts, for example after a deploy or after being evicted when idle. Every isolate sees the new generation on its next poll and clears its tier 1 once (resets +1). That is the safe behaviour. Expect it after quiet periods.
  • KV is eventually consistent. Local wrangler dev KV is strongly consistent, so the propagation lag is not covered by brust’s local tests.
  • A KV write is skipped when the same isolate wrote an identical page less than 60 seconds earlier.

Cost guidance

Prices and quotas were read from Cloudflare’s pages on 2026-10-12 and change over time. Check them before you size.

Durable Object requests. The pull broadcast makes one DO request per poll, and only an isolate that is serving requests polls (idle isolates cost nothing). A continuously busy isolate polls 86,400,000 / epoch_interval_ms times a day. Cloudflare’s Free plan includes 100,000 DO requests per day and 13,000 GB-s per day, and past a Free limit “further operations of that type will fail with an error”. Paid includes 1 million requests per month, then $0.15 per million (source C7).

epoch_interval_msPolls per day per busy isolateFree: busy isolates the daily quota coversPaid: 10 busy isolates, 30 daysWorst-case staleness
250 (minimum)345,600none (one isolate exceeds it)103.68 million requests, about $15.40about 0.25 s plus one round trip
1000 (default)86,400one (86% of the quota)25.92 million, about $3.74about 1 s plus one round trip
500017,280about 55.18 million, about $0.63about 5 s plus one round trip
60000 (maximum)1,440about 690.43 million, inside the included 1 millionabout 60 s plus one round trip

The Paid column is overage only: (requests - 1 million) x $0.15 per million; it excludes the plan’s minimum charge and other usage. A DO kept active by polls uses about 11,000 GB-s a day at 128 MB, inside the Free 13,000.

On the Free plan with steady traffic, set epoch_interval_ms to 5000 or more. When the quota runs out, polls fail, the adapter fails open and invalidation falls back to the TTL, and poll_errors rises. One DO object serves about 1,000 requests per second as a soft limit (source C10), which is roughly 1,000 busy isolates at 1 s.

KV. Free allows 1,000 writes and 100,000 reads per day. Paid includes 1 million writes and 10 million reads per month, then $5.00 and $0.50 per million (source C8). Write-through costs one KV write per tier-2 miss of a page that is long-lived enough to be stored, so the Free write quota is the binding limit: it is why the default minimum TTL is 300 s.

Sources

All fetched 2026-10-12.