15 minutes · Hands-on code · Every line explained
Listen while you read — AI-generated podcast for this lesson
Lessons 1 and 2 were about why and how. This lesson is about doing. You will read a complete, working counter DO and its companion Worker. Every line is annotated. Nothing is hand-waved.
The counter: multiple clients can increment, decrement, and read a named counter. Each counter name gets its own DO instance. The value persists across requests and restarts. Concurrent increments are safe — no transactions, no locks.
A DO deployment always has at least two parts: the Durable Object class (the instance that holds state) and the Worker (the stateless entry point that routes requests to it). They live in the same file here, but they are conceptually distinct.
import { DurableObject } from "cloudflare:workers";
DurableObject base class is provided by the Workers runtime. Extending it
gives your class access to this.ctx (state and storage) and
this.env (bindings). You must call super(ctx, env) in your
constructor — the base class does real work.
export class Counter extends DurableObject {
Counter — must match the class_name in
wrangler.jsonc. The runtime uses that binding to know which class to
instantiate when a stub is created.
async getCounterValue(): Promise<number> {
let value = (await this.ctx.storage.get("value")) || 0;
return value;
}
this.ctx.storage is the DO's private KV-style storage API. get()
returns a Promise that resolves to whatever was previously stored, or
undefined if nothing was. The || 0 handles the first-ever
read before anything is written. This read hits the in-memory cache if the value was
recently accessed; otherwise it reads from the co-located SQLite database on the same
machine.
async increment(amount = 1): Promise<number> {
let value = (await this.ctx.storage.get("value")) || 0;
value += amount;
await this.ctx.storage.put("value", value);
return value;
}
get() or the put().
By the time the second request's handler starts, the first has already finished its
write. No lock, no transaction, no retry — the runtime serializes it structurally.
async decrement(amount = 1): Promise<number> {
let value = (await this.ctx.storage.get("value")) || 0;
value -= amount;
await this.ctx.storage.put("value", value);
return value;
}
}
increment(), decrement(), and getCounterValue() are
plain async methods on the class. The Workers RPC system automatically exposes any
public method as callable from a stub. There is no special decorator or
registration step. The method name becomes the RPC name.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const name = url.searchParams.get("name");
if (!name) {
return new Response("Add ?name=something to the URL", { status: 400 });
}
const stub = env.COUNTERS.idFromName(name);
const id = env.COUNTERS.get(stub);
env.COUNTERS is the DO namespace binding (declared in
wrangler.jsonc). idFromName(name) converts the string name
to a globally unique DurableObjectId — deterministically. The same name
always produces the same ID, so the same instance. .get(id) returns a
stub: a client object that knows how to route calls to that instance. The
instance is not created or woken up yet — that happens when you call a method on the stub.
let count: number;
switch (url.pathname) {
case "/increment":
count = await stub.increment();
break;
case "/decrement":
count = await stub.decrement();
break;
case "/":
count = await stub.getCounterValue();
break;
default:
return new Response("Not found", { status: 404 });
}
return new Response(`Counter '${name}': ${count}`);
},
};
stub.increment() looks like a local method call but it is an RPC: the
runtime serializes the call, routes it to the DO instance (waking it if hibernated),
runs increment() in the DO's single-threaded context, serializes the return
value, and resolves the Promise here. The Worker waits for the Promise before returning
its response.
{
"name": "my-counter",
"main": "src/index.ts",
"durable_objects": {
"bindings": [
{
"name": "COUNTERS",
"class_name": "Counter"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["Counter"]
}
]
}
durable_objects.bindings — tells the runtime: "when this Worker references
env.COUNTERS, route it to instances of the Counter class."
The name here is what you use in your Worker code; the
class_name must match the exported class name exactly.
migrations — required when creating a new DO class. The
new_sqlite_classes array tells the runtime to provision a SQLite database
for each instance of this class. You only declare a migration once per class creation.
If you rename or delete a class later, you add another migration entry. Tags must be
unique and sequential.
GET /?name=visitsenv.COUNTERS.idFromName("visits") — deterministic ID computedenv.COUNTERS.get(id) — stub created, no network call yetawait stub.getCounterValue() — RPC sent to the DOgetCounterValue() runs: storage.get("value") returns
undefined, method returns 0"Counter 'visits': 0"
On the second request to /increment?name=visits, step 5 is skipped — the
instance is already warm. The input gate ensures the read-modify-write in
increment() is never interrupted by a concurrent call.
Portability check. If you needed to run this counter on a different
platform, the code above wouldn’t transfer directly — but the
pattern would. Microsoft Orleans would express this as a grain with a string key
and persistent state. Rivet Actors (Node.js, self-hostable) would express it as
this.state.value inside an actor class. The read-modify-write-protected-by-
single-threaded-execution is identical across all three. What changes is the API surface
and the infrastructure underneath, not the concept.
Without running it, trace through what happens when two requests arrive at the same
millisecond: GET /increment?name=hits and
GET /increment?name=hits. The counter starts at 5.
Write down: what value does each request return? What is stored after both complete? Then check your reasoning against the answer below.
One request wins the input gate. Say request A goes first. A reads 5, writes 6, returns 6. The input gate then opens. Request B reads 6 (not 5 — A's write is already confirmed), writes 7, returns 7.
Final stored value: 7. No duplicate returns. No lost increment. Neither request needed to know the other existed.
Select the best answer.
What does env.COUNTERS.idFromName("room-42") return?
Why is there no explicit transaction around the read-modify-write in
increment()?
When does the DO instance actually start (constructor runs) for the first time?
ctx.storage, including KV and SQL methods, input/output gate options, and write coalescing.