Cloudflare
Durable Objects
Programação
Estado
Concorrência

The Durable Objects programming model: what's different from anything you've ever used

How Durable Objects' programming model differs from anything in conventional serverless, with the storage, transaction, and Alarm APIs that change what's possible.

The Durable Objects programming model: what's different from anything you've ever used

Most engineers who encounter Durable Objects for the first time read them as "Workers with [embedded database]" and start writing code in that direction. The result is applications that work in testing and fail in subtle ways in production. The mental model is wrong because the difference between a DO and a Worker with access to a bank isn't one of convenience — it's where the state lives and who controls access to it.

The structure of a Durable Object class

A DO is a JavaScript class with three core elements: the constructor, the fetch() method, and this.ctx.storage access. The constructor runs once when the DO is created or wakes up from hibernation. fetch() processes each request received. And this.ctx.storage is the durable storage API that persists state between requests and between hibernations.

export class Counter implements DurableObject { private count: number = 0; constructor(private ctx: DurableObjectState, private env: Env) { this.ctx.blockConcurrencyWhile(async () => { this.count = (await this.ctx.storage.get<number>('count')) ?? 0; }); } async fetch(request: Request): Promise<Response> { this.count++; await this.ctx.storage.put('count', this.count); return new Response(String(this.count)); } }

The blockConcurrencyWhile() in the constructor is necessary when you need to hydrate the storage state before processing any request. Without it, a request that arrived before the storage await was resolved would see an incomplete state. blockConcurrencyWhile queues requests until the callback resolves — this is the way to initialize the DO safely.

What changes when the state lives in memory

The "read from bank, compute, write back" operation is the most common pattern in server-side applications and also the source of the most common race conditions. Between read and write, another process may have modified the same record. To solve this with a conventional bank, you use transactions with locks, SELECT FOR UPDATE, or some form of optimistic versioning.

In a DO, this problem does not exist in the same way. The state lives in memory — this.count already has the current value. There is no round-trip to read. The increment and persistence happen within the same execution, without another request being able to interleave. The guarantee is not from the bank: it is from the serial execution of the DO itself.

This has a cost that needs to be explicit: consistency depends on you persisting the state you modified in memory. If the DO hibernates before a put() is called, the memory modification is lost. Every change that matters needs to be persisted in storage before the request ends. The correct pattern is to modify and persist the same operation, without relying on a later flush.

The storage API and when to use transaction()

The storage API has direct operations: get(), put(), delete(), list(). All are individually atomic and durable — what was written is guaranteed to be on disk even if the DO hibernates immediately afterwards. Linearizability applies to the entire storage of an instance: any reading after put() will see the value written, without exception.

When a logical operation modifies multiple keys and they all need to be consistent with each other, transaction() is the correct tool:

await this.ctx.storage.transaction(async (txn) => { const balance = await txn.get<number>('balance') ?? 0; await txn.put('balance', balance - amount); await txn.put('last_debit', Date.now()); await txn.put('debit_count', (await txn.get<number>('debit_count') ?? 0) + 1); });

If the transaction() callback throws an exception, none of the writes are persisted. The storage state returns to what it was before the transaction began. There is no partial commit. This replaces the need for compensating transactions or rollback logic in the application for operations that affect multiple keys.

An operational detail of list(): by default returns a maximum of 128 entries. For larger sets, use the cursor returned in the response to page. Ignoring this is discovering the limit in production with real data.

The Alarm API: setTimeout that survives hibernation

Workers have setTimeout(), but the timer does not survive isolate hibernation. When the Worker finishes processing the request, any pending timers disappear. For a DO that needs to do something at a future point — purging expired sessions, resending a failed message, invalidating a lock after a timeout — the Alarm API is the right mechanism:

async fetch(request: Request): Promise<Response> { const body = await request.json() as { lockKey: string; ttlMs: number }; await this.ctx.storage.put(`lock:${body.lockKey}`, true); await this.ctx.storage.setAlarm(Date.now() + body.ttlMs); return new Response('lock acquired'); } async alarm(): Promise<void> { const keys = await this.ctx.storage.list({ prefix: 'lock:' }); for (const key of keys.keys()) { await this.ctx.storage.delete(key); } }

setAlarm() receives an absolute timestamp in milliseconds. When DO sleeps, Cloudflare maintains the scheduled alarm. At the right time, the DO wakes up and method alarm() is invoked. The cost is $0.15/million invocations — the same price list as normal requests.

It is only possible to have one active alarm per DO at a time. setAlarm() replaces the previous one. If you need multiple timers, the strategy is to store the list of upcoming events in the storage, schedule an alarm for the closest one, and when alarm() runs, process the expired events and reschedule for the next one.

How routing to the right instance works

Durable Objects have three ID generation strategies, and the choice determines the routing behavior.

idFromName("room-123") is deterministic: the same name always produces the same ID, globally. It's the strategy for when you want all customers who request "room-123" to arrive at the same DO. The ID is derived by hashing the name, and two DOs with the same name in the same namespace are the same object.

newUniqueId() generates a random ID — always a new DO. Use when you create an entity and will store the ID for future reference without needing to derive it from a key.

idFromString(hexStr) reconstructs an ID from a hexadecimal string that you previously stored. Useful when the ID was generated with newUniqueId(), persisted in an external database or in the KV, and you need to reference the same DO later.

Location hints allow you to suggest a geographic jurisdiction (EU, US), but do not guarantee a specific PoP. The DO is created in the PoP closest to the first request that instantiated it. For applications with EU data residency requirements, locationHint: 'eu' directs creation to a European PoP, but Cloudflare chooses which one.

How does this connect to the Worker that routes

The Worker that receives the client's request needs to obtain the DO stub and forward the request:

export default { async fetch(request: Request, env: Env): Promise<Response> { const roomId = new URL(request.url).searchParams.get('room') ?? 'default'; const id = env.ROOMS.idFromName(roomId); const stub = env.ROOMS.get(id); return stub.fetch(request); } };

The stub has the same fetch() contract as a normal Worker. The Worker that routes does not have access to the DO storage, cannot read its internal state, and cannot call methods other than fetch() and RPC (when configured with WorkerEntrypoint). Communication is via normal HTTP requests.

Durable Objects are not a database abstraction. They are a stateful computing model. Treating them like a database that also processes logic, rather than an object with identity and state, is what leads to confusion — and code that works in production by accident, not by design.

Also read