Overview

A Durable Object is a single-instance actor with persistent storage. One ID maps to exactly one running instance in exactly one Cloudflare location, and all reads and writes to that instance are linearizable. Reach for Durable Objects when the workload needs serialized state, coordination, or a long-lived WebSocket. Stay on KV when the workload is read-heavy and tolerates a minute of staleness.

One ID, one instance, one writer

The defining property of a Durable Object is uniqueness. The runtime guarantees that requests for a given object ID land on the same instance.

  • Coordination problems collapse to “send a message to this object.” No locks, no leader election.
  • Storage is per-object and transactional. Writes inside a single request handler commit atomically.
  • The instance lives near the first caller and stays there. Workers calling that object pay one cross-region hop at most.

This is the actor model, expressed as a Cloudflare primitive. The instance is the actor; the ID is the address; the methods on the class are the message handlers.

Define the class and bind it in wrangler.toml

A Durable Object is a class exported from the Worker bundle.

export class Counter {
  state: DurableObjectState
  constructor(state: DurableObjectState) {
    this.state = state
  }
  async fetch(req: Request) {
    let count = (await this.state.storage.get<number>("count")) ?? 0
    count++
    await this.state.storage.put("count", count)
    return Response.json({ count })
  }
}
 
export default {
  async fetch(req: Request, env: { COUNTER: DurableObjectNamespace }) {
    const id = env.COUNTER.idFromName("global")
    const stub = env.COUNTER.get(id)
    return stub.fetch(req)
  },
}
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
 
[[migrations]]
tag = "v1"
new_classes = ["Counter"]

idFromName is a deterministic hash; the same name maps to the same instance forever. newUniqueId returns a fresh ID. Pick idFromName for “one per user” or “one per room”; pick newUniqueId when the ID is the object’s identity.

Hibernate WebSockets to scale to many idle connections

Durable Objects host WebSockets natively. The hibernation API lets the runtime evict idle connections from memory while keeping them open at the network layer.

async fetch(req: Request) {
  const pair = new WebSocketPair()
  this.state.acceptWebSocket(pair[1])
  return new Response(null, { status: 101, webSocket: pair[0] })
}
 
webSocketMessage(ws: WebSocket, message: string) {
  ws.send(`echo: ${message}`)
}

acceptWebSocket (vs ws.accept()) opts into hibernation. The class only stays in memory while messages flow; between messages the runtime can swap it out and bring it back without dropping the socket. A single object can handle 32,000 concurrent connections this way.

Schedule work with alarms

Alarms are per-object timers. Set one, and the runtime calls alarm() on the instance at that time, even if no client is connected.

async fetch() {
  await this.state.storage.setAlarm(Date.now() + 60_000)
  return new Response("scheduled")
}
async alarm() {
  // Runs 60 seconds after setAlarm, even if the object had no traffic.
  await this.flush()
}

Alarms replace cron triggers for per-object schedules: expire a session after 30 minutes of idle, debounce a batch flush, retry a failed delivery with backoff. Only one alarm fires at a time per object; setting a new alarm overwrites the previous one.

Trust the transactional storage API

state.storage is a key-value store scoped to the object. Reads and writes inside a single request handler form an implicit transaction; if the handler throws, the writes roll back.

  • state.storage.get, put, delete, list. List paginates and supports prefix and range queries.
  • state.blockConcurrencyWhile(async () => { ... }) serializes work and prevents interleaving until the callback resolves.
  • Storage caps at 50 GB per object on the paid plan and 128 MB per value.

For workloads that need SQL, look at the SQLite-backed Durable Objects API or fall back to D1 or postgres-prod for cross-object queries.

Pick Durable Objects over KV when consistency matters

The decision is mostly about consistency and write rate.

  • Use a Durable Object when one writer must see its own writes immediately: counters, balances, rate limiters under contention, chat rooms, multiplayer game state, leader election, debounced fanout.
  • Use cloudflare-kv when the workload is read-heavy and stale-by-a-minute is fine: feature flags, session caches, config blobs.
  • Use cloudflare-r2 for blobs, not for state.
  • Use postgres-prod when the workload needs joins, multi-row transactions, or analytical queries across the whole dataset.

The boring rule: KV is a cache, R2 is a bucket, Durable Objects are the database for one thing. Pick by access pattern, not by enthusiasm.