After this lesson you'll be able to explain what a Durable Object is and why Workers alone can't do strongly-consistent coordination, write a minimal counter Durable Object with persistent storage, and avoid the "hot object" bottleneck when choosing IDs.
A normal Worker is stateless and can run as many concurrent copies as traffic demands, anywhere on Cloudflare's network. That's great for throughput, but it means two requests hitting "the same" Worker have no shared memory and no way to coordinate — there's no single place to say "wait your turn" or "here's the current value." A Durable Object fixes that by being the opposite: a single, globally-unique instance of a class that all requests for a given ID are routed to, with its own persistent storage attached. It's a Worker with a memory and a mailbox that only one letter is opened at a time.
Three ideas make Durable Objects tick:
"room-42", or a random one Cloudflare generates), and every request for that ID — from anywhere in the world — is routed to the exact same object instance. There is exactly one live instance of a given ID at a time, network-wide.ctx.storage. Because storage lives with the object (not on a separate network hop), reads are fast and writes are consistent — no eventual-consistency lag like Workers KV.Lifecycle-wise, an object is created lazily near wherever its first request comes from, stays in memory (with in-memory state you can cache between requests) while it's active, and is evicted after a period of inactivity — its durable storage persists even after eviction, and the object simply wakes up again on the next request.
This is close to the canonical Durable Objects example: a per-ID counter that increments on every request and remembers its value across restarts.
// src/counter.ts
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}
async fetch(request: Request): Promise<Response> {
// this.ctx.storage is transactional and colocated with the object
let count: number = (await this.ctx.storage.get("count")) ?? 0;
if (request.method === "POST") {
count += 1;
await this.ctx.storage.put("count", count);
}
return Response.json({ count });
}
}
The Worker that fronts it picks which counter to talk to by deriving an ID from the request — here, one counter per URL path segment:
// src/index.ts
export { Counter } from "./counter";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const name = url.pathname.slice(1) || "default";
// Same name -> same object, every time, everywhere
const id = env.COUNTER.idFromName(name);
const stub = env.COUNTER.get(id);
return stub.fetch(request);
},
} satisfies ExportedHandler<Env>;
// wrangler.jsonc
{
"durable_objects": {
"bindings": [
{ "name": "COUNTER", "class_name": "Counter" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Counter"] }
]
}
idFromName("checkout-cart-99") always resolves to the same object, so every client hitting /checkout-cart-99 — from any datacenter — is coordinating through the same single-threaded instance and the same storage.
Durable Objects billing has three components: requests, active compute duration, and storage. As of this writing (SQLite-backed Durable Objects are the current default and recommended storage backend):
| Meter | Free plan | Paid plan (Workers Paid) |
|---|---|---|
| Requests | 100,000 / day | 1,000,000 / month included, then $0.15 / million |
| Duration (active compute) | 13,000 GB-s / day | 400,000 GB-s / month included, then $12.50 / million GB-s |
| Rows read (SQLite storage) | 5,000,000 / day | 25 billion / month included, then $0.001 / million |
| Rows written (SQLite storage) | 100,000 / day | 50 million / month included, then $1.00 / million |
| Stored data | 5 GB total | 5 GB-month included, then $0.20 / GB-month |
Duration is billed only while an object is active in memory (128 MB is the billing unit for compute duration) — an idle, hibernated object costs nothing in duration, which matters a lot for the WebSocket use case below. Cloudflare also has an older key-value storage backend billed differently (per read/write/delete unit rather than rows); new Durable Object classes should default to the SQLite-backed storage.
Cloudflare Docs — Durable Objects is the canonical overview of the product; see the pricing page specifically for current rates, since this lesson's numbers are a point-in-time snapshot (checked 2026-07-03).
A Durable Object's storage is colocated with the single instance that owns it, and that instance processes requests one at a time — so there's exactly one place any read or write can happen, with no replication lag to reason about. Workers KV, by contrast, replicates data to many edge locations for fast global reads, which means a write in one location takes time to propagate to others — that propagation window is what "eventually consistent" refers to.