Skip to content
Sponsor

Cloudflare Workers

Compose the current Cache source with Cloudflare KV, a Coordinator Durable Object, and the Workers factory.

The current source exposes a Cloudflare entry point at @astilba/cache/cloudflare. Its createWorkersCache() factory combines the portable kernel with a bounded memory L1, Cloudflare KV L2, a Coordinator Durable Object, a live WebSocket Bus, and reconnecting recovery behavior.

You provide one stable registry name and two Cloudflare bindings:

cache.server.ts
import { env } from "cloudflare:workers"
import { createWorkersCache } from "@astilba/cache/cloudflare"
export const cache = createWorkersCache({
name: "storefront",
kv: env.CACHE_KV,
coordinator: env.COORDINATOR,
})

The Worker entry must separately export the Durable Object class so Wrangler can bind it:

worker.ts
export { Coordinator } from "@astilba/cache/cloudflare"
export default {
async fetch(): Promise<Response> {
return new Response("Worker ready")
},
} satisfies ExportedHandler

The name is one identity, not a display label. The factory uses it as:

  • the named Coordinator Durable Object address;
  • the Registry and replication-mirror identifier;
  • the Cache namespace used in canonical keys.

Keep it stable for the lifetime of the cache domain. Changing it addresses a different Durable Object and a different keyspace.

The factory also supplies:

  • a Workers wall-clock Clock and random Rng at the platform boundary;
  • memory({ clock, maxEntries: 512, maxBytes: 5_000_000 }) as L1;
  • cloudflareKV() as L2;
  • doRegistry() against the named Coordinator;
  • a self-redialing doBus() connection;
  • eventual consistency, live Registry checks for unknown knowledge, the default HTTP retry classifier, and a 30-second reader heartbeat interval unless you override those fields in defaults.

The Worker must export Coordinator, bind that class as a SQLite-backed Durable Object, and make one KV namespace visible under two binding names:

wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "storefront-worker",
"main": "src/worker.ts",
"compatibility_date": "2026-07-15",
"durable_objects": {
"bindings": [
{ "name": "COORDINATOR", "class_name": "Coordinator" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Coordinator"] }
],
"kv_namespaces": [
{ "binding": "CACHE_KV", "id": "<your-kv-namespace-id>" },
{ "binding": "REGISTRY_KV", "id": "<your-kv-namespace-id>" }
],
"vars": {
"REGISTRY_HEARTBEAT_MS": "30000"
}
}

Both KV bindings point to the same namespace. CACHE_KV is the L2 Store the reader sees; REGISTRY_KV is where the Coordinator writes replication pointers, deltas, and snapshots. If they point at different namespaces, the reader cannot recover from the mirror the Coordinator produced.

New Durable Object classes use new_sqlite_classes. See Cloudflare’s Durable Object migration guide before merging this into an existing migration history.

createWorkersCache() configures the reader to expect a 30-second heartbeat interval. The Coordinator cannot set its own deployment variables, so you must separately opt its idle heartbeat in with:

"vars": { "REGISTRY_HEARTBEAT_MS": "30000" }

Leaving the variable unset keeps idle heartbeats dormant. That avoids a recurring Durable Object alarm and KV write for every idle registry, but a reader can become conservatively suspicious and pay for live checks until it reconverges. Setting it to 30000 matches the factory’s reader default.

If you override defaults.heartbeatInterval, update the deployment variable deliberately as well. The two settings are not synchronized by the library.

Once constructed, application code uses the same portable API:

load-product.ts
import { t } from "@astilba/cache"
import { cache } from "./cache.server"
export async function getProduct(productId: string) {
return cache.getOrSet({
key: `product:${productId}`,
tags: [t`product:${productId}`],
factory: ({ signal }) => loadProduct(productId, signal),
})
}

After updating the source of truth, invalidate the same dependency:

await saveProduct(productId, input)
await cache.delete({ tag: t`product:${productId}` })

The mutation reaches the authoritative Coordinator. Active isolates receive live Bus events; suspect readers can recover through the KV mirror. A strong read performs a live check before serving a stored entry and before filling a strong miss.

The source path currently includes:

  • KV value-size rejection and write-failure classification;
  • Coordinator command journaling, coalesced flushes, snapshots, and Registry RPC;
  • WebSocket Bus delivery with scope checks and jittered redial backoff;
  • reactive read-path recovery plus an out-of-band polling state machine;
  • a request-piggyback poll driver when the React Router middleware is used.

It does not yet provide:

  • an npm release or supported upgrade policy;
  • elapsed TTL, grace, or age enforcement;
  • journal checkpointing and truncation for a long-lived Coordinator;
  • a production Lock or CDN purge driver;
  • automatic shared-response Cache-Tag emission;
  • the chaos demo and deployed consistency measurements that complete the Workers release path.

Continue with React Router if that is your server framework, Driver implementations for component-level status, or Implementation status for kernel limitations.