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:
- Exactly one live instance per ID anywhere in the world at any moment.
- All requests to that ID route to that instance, serialised in arrival order.
- The instance has access to private, transactional storage that's bound to its ID and persisted durably.
That's the whole abstraction. Everything else — websockets, alarms, RPC — is built on top of it.
The contrast with the other Cloudflare storage primitives:
| Primitive | Consistency | Concurrent writers | Per-entity coordination |
|---|---|---|---|
| R2 | Strong for single object | N/A | No |
| D1 | Strong (primary) | One DB-wide writer | Indirect (SQL constraints) |
| KV | Eventual (~60s) | Last-write-wins | No |
| Durable Object | Strong | One per instance, by design | Yes — 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:
- Hibernation drops in-memory state, but not storage. Anything you cached on
thisis gone when the DO wakes. Anything inthis.state.storageis still there. - 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:
- Object placement hints.
env.NAMESPACE.idFromName(name, { jurisdiction: 'eu' })andlocationHint: 'wnam'(etc.) let you bias the DO to a region close to its readers. The right hint for a per-customer DO is usually "the region the customer signed up from." - Use DOs as the authority, not as the request path. Workers and KV serve the fast read path; the DO is the source of truth that workers consult only on write or on cache miss. This is the same pattern as "KV in front, D1 behind," except the D1 layer is a DO.
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
- One alarm per DO at a time — overwrite to reschedule.
- 128 MB transactional batch limit — fine for normal use, blocks "load 10k rows in one transaction."
- Storage per object is large (up to GBs on the SQLite engine) but you should treat any single DO as holding one entity's worth of data, not a tenant-wide warehouse.
- One instance, one location. If that location's network is degraded, requests to that DO are degraded. The runtime can migrate the instance, but it isn't a multi-region replica.
The pros and cons cheat sheet
Pros
- Strong consistency, by construction. The "actor model" maps directly onto the problems that wreck KV.
- Transactional storage, including the SQL flavour for new objects.
- Hibernating websockets make real-time features economically free at low concurrency.
- One name, one instance, anywhere. No lock service, no coordination layer to operate.
- Alarms give you per-entity scheduling without a queue.
Cons
- Single location — adds RTT for distant clients; degrades with the colo.
- No cross-DO queries — you have to denormalise to D1 if you want them.
- Cold-start on wake-up — usually ms, occasionally seconds for very cold instances.
- Learning curve. The actor model is unfamiliar if you're used to "stateless function + Postgres."
- Vendor-specific. The API is Cloudflare-shaped; there's no portable equivalent.
When to reach for a Durable Object
Use a DO when any of the following is true:
- You need exactly-once or single-writer semantics for one entity (one customer, one room, one document).
- You're building real-time features (chat, presence, multiplayer) and want hibernation.
- You need a rate limiter or counter that has to be correct, not approximate.
- You're tempted to add Redis or a queue just to coordinate one piece of state.
Skip DOs when:
- The data is naturally cross-entity (analytics, search, leaderboards) — that's D1 territory.
- The state is small, public, and read-mostly — that's KV territory.
- You want a single global writer per region, e.g. an admin dashboard — D1 is simpler.
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.
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