Cloudflare KV and Durable Objects
Workers only. KV holds the page bodies; a Durable Object tells every isolate what was invalidated.
$ bun add @brust/cache-cloudflarePackage @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. |
[cache]
adapter = "cloudflare-kv"
broadcast = "cloudflare-do"
epoch_interval_ms = 5000
[cache.cloudflare]
kv_min_ttl_s = 300Bindings 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:
{
"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.
| Key | Used by | Default | Meaning |
|---|---|---|---|
kv_binding | KV, pair | "BRUST_CACHE" | Name of the KV binding. |
kv_min_ttl_s | KV, pair | 300 | 0..31536000. A route whose TTL is smaller is not written to KV (KV’s own floor is 60 s). |
do_binding | DO, pair | "BRUST_CACHE_DO" | Name of the Durable Object binding; its class_name must be BrustCacheDO. |
do_store | cloudflare-do | false | true stores pages in 16 Durable Object shards. Ignored with the pair. |
[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.
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
| Config | Store | Other isolates after cache.invalidate | A stale page served again? | Free-plan cost driver |
|---|---|---|---|---|
cloudflare-kv alone | KV, unguarded | up 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 = false | none (tier 1 only) | within epoch_interval_ms plus one round trip | no | 1 DO request per poll per isolate |
cloudflare-do, do_store = true | DO shards, exact write guard | within epoch_interval_ms plus one round trip | no (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 floors | within epoch_interval_ms plus one round trip | no, once the isolate knows the invalidation | 1 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 devKV 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_ms | Polls per day per busy isolate | Free: busy isolates the daily quota covers | Paid: 10 busy isolates, 30 days | Worst-case staleness |
|---|---|---|---|---|
| 250 (minimum) | 345,600 | none (one isolate exceeds it) | 103.68 million requests, about $15.40 | about 0.25 s plus one round trip |
| 1000 (default) | 86,400 | one (86% of the quota) | 25.92 million, about $3.74 | about 1 s plus one round trip |
| 5000 | 17,280 | about 5 | 5.18 million, about $0.63 | about 5 s plus one round trip |
| 60000 (maximum) | 1,440 | about 69 | 0.43 million, inside the included 1 million | about 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.