Learn Durable Objects › Lesson 3 of 4

Build a counter DO

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.

The two files

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.

The Durable Object class

src/index.ts
import { DurableObject } from "cloudflare:workers";
Why this import The 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 {
The export matters This class must be exported so the runtime can find it. The name here — 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 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;
  }
Read-modify-write — safe here, dangerous elsewhere In a normal async JavaScript context this pattern is a race condition: two concurrent callers could both read the same value before either writes. Inside a DO it is safe. The input gate from Lesson 2 blocks any other handler from running while this handler is awaiting the 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.

The comment in the official example says exactly this: "You do not have to worry about a concurrent request having modified the value in storage. Input gates will automatically protect against unwanted concurrency."
  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;
  }
}
RPC methods 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.

The Worker (entry point)

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 });
    }
The Worker is stateless — always This is a normal Worker fetch handler. It has no persistent state. It reads the request, decides which DO to talk to, talks to it, and returns the response. All state lives in the DO, not here.
    const stub = env.COUNTERS.idFromName(name);
    const id = env.COUNTERS.get(stub);
Two steps to get a 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}`);
  },
};
Calling RPC methods on the stub 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.

The wrangler config

wrangler.jsonc
{
  "name": "my-counter",
  "main": "src/index.ts",
  "durable_objects": {
    "bindings": [
      {
        "name": "COUNTERS",
        "class_name": "Counter"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["Counter"]
    }
  ]
}
Two required config blocks
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.

What happens on the first request

  1. Worker receives GET /?name=visits
  2. Worker calls env.COUNTERS.idFromName("visits") — deterministic ID computed
  3. Worker calls env.COUNTERS.get(id) — stub created, no network call yet
  4. Worker calls await stub.getCounterValue() — RPC sent to the DO
  5. Runtime finds no active instance for this ID, cold-starts it: constructor runs
  6. getCounterValue() runs: storage.get("value") returns undefined, method returns 0
  7. Worker returns "Counter 'visits': 0"
  8. DO stays alive in memory, waiting for more requests

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.


Challenge

Hands-on

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.

Show answer

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.


Quiz

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?

Primary sources for this lesson Build a counter — Cloudflare Docs example — The canonical counter example this lesson is based on.

SQLite-backed Durable Object Storage API — Full reference for ctx.storage, including KV and SQL methods, input/output gate options, and write coalescing.
← Lesson 2: Anatomy of a Durable Object Lesson 4: Add WebSockets →
Questions? Ask your teacher before moving on. Examples: “Why does the stub need two steps — idFromName then get?” “What happens if I store an object instead of a number?” “Can the Worker call methods on two different DOs in one request?”