---
title: "Cloudflare: Durable Objects"
slug: "cloudflare-durable-objects"
category: "ops"
tags: ["ops", "cloudflare", "durable-objects", "actor-model", "websockets"]
status: "stable"
last_updated: 2026-08-14
summary: "Single-instance stateful actors at the edge: WebSockets, hibernation, alarms, transactional storage, and the rules for picking DO over KV."
related: ["[[ops/cloudflare]]", "[[ops/cloudflare-workers]]", "[[ops/cloudflare-kv]]", "[[ops/cloudflare-r2]]", "[[ops/postgres-prod]]", "[[backend/postgres]]"]
---

## 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.

```ts
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)
  },
}
```

```toml
[[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.

```ts
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.

```ts
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.
- The SQLite storage backend is now the default and the only backend available on the free plan: 10 GB per object on paid (5 GB total per account on free), 2 MB combined per key and value. The older key-value backend remains for existing paid namespaces only: 50 GB per account (raisable by request), unlimited per object, but a much smaller 128 KiB per-value cap.

Pick SQLite-backed Durable Objects for new work; it is the GA default and gives real SQL (`state.storage.sql`) instead of a flat key-value API. For workloads that need cross-object SQL, fall back to D1 or [[ops/postgres-prod]].

## 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 [[ops/cloudflare-kv]] when the workload is read-heavy and stale-by-a-minute is fine: feature flags, session caches, config blobs.
- Use [[ops/cloudflare-r2]] for blobs, not for state.
- Use [[ops/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.

## Related

- [[ops/cloudflare]]
- [[ops/cloudflare-workers]]
- [[ops/cloudflare-kv]]
- [[ops/cloudflare-r2]]
- [[ops/postgres-prod]]
- [[backend/postgres]]
