Tutorials Cloudflare Feature Focus

Durable Objects: Strong Consistency at the Edge

CloudflareCloudflare Feature Focus28 minMay 19, 2026Intermediate

KV is fast but eventually consistent. D1 is consistent but single-writer in one region. What if you need both — strong consistency and coordinated mutation from many regions, ideally with a real-time channel and no shared infra to operate?

That's the gap Durable Objects fill. A Durable Object (DO) is a small, single-instance actor with attached transactional storage that you can address by name. Cloudflare instantiates exactly one of it at a time, somewhere on the network, and routes every request addressed to that name to that one instance. If you've ever wanted "a tiny private Redis just for this customer," or "the canonical chat room for this conversation," or "the idempotency key for this webhook" — that's a Durable Object.

This chapter is the practical tour: the actor model, the storage API, websocket hibernation, the patterns that earn DOs their keep, and the costs (financial and architectural) of pinning state to a single location.

The mental model: one actor per name

A Durable Object class is a JavaScript / TypeScript class. The Worker requests an instance by name (or by ID), and the runtime guarantees:

That's the whole abstraction. Everything else — websockets, alarms, RPC — is built on top of it.

The contrast with the other Cloudflare storage primitives:

PrimitiveConsistencyConcurrent writersPer-entity coordination
R2Strong for single objectN/ANo
D1Strong (primary)One DB-wide writerIndirect (SQL constraints)
KVEventual (~60s)Last-write-winsNo
Durable ObjectStrongOne per instance, by designYes — the instance IS the lock

That bottom row is the unique trick. If you want "this customer's subscription state must be consistent across every Worker that touches it, even under concurrent Stripe webhook deliveries and concurrent user requests," a DO keyed by customerId is the cleanest answer in the catalogue.

Anatomy of a Durable Object

Two things have to happen to get a DO online: declare the class, and bind a namespace.

// src/subscription-do.ts
export class Subscription {
  state: DurableObjectState;
  env: Env;
 
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }
 
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
 
    if (url.pathname === '/get') {
      const current = (await this.state.storage.get('record')) ?? null;
      return Response.json({ record: current });
    }
 
    if (url.pathname === '/update' && request.method === 'POST') {
      const event = await request.json();
 
      // Idempotency — the same event.id can fire twice and we only act once.
      const seen = await this.state.storage.get('event:' + event.eventId);
      if (seen) return new Response('duplicate', { status: 200 });
 
      const next = applyStripeEvent(
        (await this.state.storage.get('record')) ?? defaultRecord(),
        event,
      );
 
      await this.state.storage.put({
        record: next,
        ['event:' + event.eventId]: 1,
      });
      return Response.json({ ok: true, record: next });
    }
 
    return new Response('Not found', { status: 404 });
  }
}
# wrangler.toml
[[durable_objects.bindings]]
name = "SUBSCRIPTIONS"
class_name = "Subscription"
 
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Subscription"]

From a Worker, you talk to it like this:

const id = env.SUBSCRIPTIONS.idFromName(stripeCustomerId);
const stub = env.SUBSCRIPTIONS.get(id);
const res = await stub.fetch('https://internal/update', {
  method: 'POST',
  body: JSON.stringify(stripeEvent),
});

idFromName(name) is a deterministic hash — the same string always maps to the same DO. That's how every Worker, in every region, addresses the same customer's state.

Storage: transactional and small

Every DO has its own attached storage namespace. As of 2024, new DOs use a SQLite-backed storage engine (declared via new_sqlite_classes in your migration), which is what you want for new code. The legacy KV-backed flavour still exists for old DOs.

The high-level surface most people use:

// Single-key
await this.state.storage.get('record');
await this.state.storage.put('record', { tier: 'pro', expires: '2027-01-01' });
await this.state.storage.delete('record');
 
// Multi-key — atomic across keys for this object
await this.state.storage.put({
  record: next,
  lastEvent: event.id,
});
 
// Prefix listing
const entries = await this.state.storage.list({ prefix: 'event:', limit: 100 });
 
// Transactional callback
await this.state.storage.transaction(async (tx) => {
  const count = ((await tx.get('count')) as number) ?? 0;
  await tx.put('count', count + 1);
});

The thing to internalise: storage access inside a DO is local to the colo where the DO lives — there's no cross-region round trip. Reads are typically sub-millisecond, writes are tens of milliseconds (the disk + the durability fan-out). That's wildly faster than KV's miss path or D1's primary-region round trip.

For SQLite-backed DOs, you can also drop down to raw SQL via this.state.storage.sql.exec(...), which is the right call when the data is genuinely relational and you'd rather not hand-roll multi-key indexes. The DO becomes, in effect, your private per-tenant SQLite database.

Hibernation: the cost story

A DO doesn't run continuously. The runtime evicts idle instances and resumes them on the next request — invisible to your code. The pricing reflects this: you're billed for active duration, not wall-clock existence.

Hibernation also extends to websockets. A DO can hold thousands of websocket connections open, then go to sleep when nothing's happening, then wake up the instant a message arrives — and Cloudflare doesn't bill you for the idle time. This is the trick that makes per-conversation chat rooms, per-game lobbies, per-document collaboration cursors economically viable at scale.

The hibernation-aware websocket API uses acceptWebSocket instead of accepting via the WebSocket API directly:

async fetch(request: Request): Promise<Response> {
  const upgrade = request.headers.get('Upgrade');
  if (upgrade !== 'websocket') return new Response('Expected WS', { status: 426 });
 
  const pair = new WebSocketPair();
  this.state.acceptWebSocket(pair[1]);
  return new Response(null, { status: 101, webSocket: pair[0] });
}
 
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
  // Called when a message arrives — DO is woken from hibernation if needed.
  await this.broadcast(msg);
}
 
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
  // Clean-up when a client disconnects.
}

Two things to know:

  1. Hibernation drops in-memory state, but not storage. Anything you cached on this is gone when the DO wakes. Anything in this.state.storage is still there.
  2. Hibernating websockets cost nothing while idle, but the first message of a new burst pays a "warmup" — usually small, occasionally visible.

Alarms: scheduled wake-ups

Every DO can set exactly one alarm — a future timestamp at which the runtime will fire alarm() on the instance. Alarms persist across hibernation and restarts; they're the right tool for "do something for this entity in 30 minutes" without a separate scheduler.

async fetch(request: Request) {
  // Schedule a flush 60 seconds from now
  await this.state.storage.setAlarm(Date.now() + 60_000);
  return new Response('ok');
}
 
async alarm() {
  const buffered = await this.state.storage.list({ prefix: 'pending:' });
  // ... do the batched work ...
  await this.state.storage.delete([...buffered.keys()]);
}

Idiomatic uses: deferred metric flushes, debouncing user input, retrying a failed external call, cleaning up an inactive game lobby.

Patterns where DOs actually win

1. Idempotent webhook fan-out (the SAS pattern)

Stripe will retry a webhook if you don't respond 2xx quickly enough. Two deliveries of the same event are normal. Your code must handle them correctly — and "correctly" means exactly once state update.

The DO version is clean:

const id = env.SUBSCRIPTIONS.idFromName(String(customerId));
const stub = env.SUBSCRIPTIONS.get(id);
await stub.fetch('https://internal/update', {
  method: 'POST',
  body: JSON.stringify({ eventId, eventType, payload }),
});

Inside the DO, the very first thing the handler does is get('event:' + eventId). If the key exists, this is a replay — return 200 duplicate. Otherwise apply the state change and mark the event seen, in a single multi-key put. The DO's per-customer serialisation guarantees this is atomic without any locking infrastructure.

2. Rate limiting with arithmetic that's actually correct

The "rate limit in KV" anti-pattern fails because two writers can both read 5 and both write 6. A DO sees one request at a time:

async fetch(request: Request) {
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - (now % 60); // 60-second window
  const key = 'win:' + windowStart;
 
  const current = ((await this.state.storage.get(key)) as number) ?? 0;
  if (current >= 60) return new Response('rate-limited', { status: 429 });
 
  await this.state.storage.put({ [key]: current + 1 });
  // Schedule cleanup
  await this.state.storage.setAlarm(Date.now() + 90_000);
  return new Response('ok');
}

A DO keyed by user ID, IP, or API key gives you a globally-correct rate limit with no Redis bill and no race condition.

3. Real-time collaboration

A DO is the obvious home for the canonical state of a thing — a document, a board, a poker hand, a chat room. Clients connect via websocket, the DO broadcasts mutations to every client, and hibernation makes idle rooms free. This is essentially how every "collaborative tool" on Workers is built today.

4. Per-customer counters and quotas

D1 is fine for counter-shaped data at low write rates — but when a single counter is hammered (think: "API requests this month for this customer"), the D1 primary becomes the bottleneck. A DO keyed by customer ID absorbs the writes locally, then flushes a summary to D1 on an alarm. You trade some staleness in the global view for unbounded write throughput on the per-entity view.

The costs of single-instance coordination

A DO lives in one location at a time. That's the source of its consistency guarantee and also the source of its main downside: a user in Tokyo talking to a DO that booted in Frankfurt pays a 200ms RTT per request.

There are two mitigations:

The second cost is architectural: the strong-consistency model means you can't query "every DO of class X" with a single call. There's no SELECT * FROM Subscriptions WHERE expired_at < now(). If you need cross-entity queries, the DO writes the relevant fields out to D1 on every change, and D1 is the read-side for cross-cutting queries.

Pricing in one paragraph

Durable Objects on the Workers Paid plan are billed in three lines: requests at $0.15/M (with a million-request free tier), active duration at $12.50/M GB-sec (with 400k GB-sec free) — and storage for SQLite-backed objects at the SQLite-storage rate. Hibernating websockets cost nothing while idle. For an indie app with thousands of DOs that each see a handful of requests per day, the bill is usually under $5/mo.

Limits worth knowing

The pros and cons cheat sheet

Pros

Cons

When to reach for a Durable Object

Use a DO when any of the following is true:

Skip DOs when:

The next chapter looks at Workers AI — the env.AI binding, the free-tier image model SAS uses for community asset generation, and how to pick between Workers AI and an external provider like OpenRouter.

Ch 4: KV — The Edge Key-Value StoreCh 6: Workers AI — Free Inference at the Edge
Course PlatformBuild a Course Platform on CloudflareBuild a paid video course platform with Cloudflare Workers, R2, D1, auth, Stripe, and paywalls.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access