12k
All articles

A Beginner's Guide to Cloudflare Durable Objects

Cloudflare Durable Objects explained: routing, single-instance state, SQLite storage, and a TypeScript rate limiter example with Workers.

OpenReplay Team
OpenReplay Team
A Beginner's Guide to Cloudflare Durable Objects

A Durable Object is a single instance of a JavaScript class that Cloudflare runs in exactly one place at a time; every request that names that instance is routed to it, wherever in the world the request originated, and the instance carries its own private storage.

Build a counter, a lock, or a “who is in this room” list on plain Workers and two requests can end up disagreeing. The Worker that handled the first request and the Worker that handled the second may be different isolates in different cities with no shared memory between them.

This guide explains the routing model that makes Durable Objects work, then shows the smallest TypeScript that demonstrates it, using a per-API-key rate limiter as the single running example. It assumes you already know bindings, wrangler and the fetch handler; if you need that background first, start with the OpenReplay beginner’s guide to Cloudflare Workers.

Key Takeaways

  • Durable Objects are a compute primitive that carries its own private storage, not a storage product you read from a Worker; the storage is reachable only from code running inside the object.
  • The string passed to env.BINDING.getByName(name) is the object’s identity: every request across Cloudflare’s network that passes the same string reaches the same running instance.
  • Each Durable Object owns an embedded SQLite database on the same thread as its code, so this.ctx.storage.sql.exec() returns a cursor synchronously and needs no await.
  • Class fields survive between consecutive requests but are discarded when the object hibernates after about 10 seconds of inactivity; anything that must survive belongs in ctx.storage.
  • Use Workers KV when many locations read the same data and a write may take time to become visible everywhere; use a Durable Object when several clients must agree on the current value at the same moment.

Why Do Stateless Workers Fail at Coordination?

A Worker keeps nothing between requests. Two calls can land on different isolates, in different places, and neither can see what the other did, so any feature that needs consecutive requests to agree on a value breaks. Cloudflare’s own design guidance for Durable Objects draws exactly this line between stateless Workers and stateful coordination.

Here is the naive rate limiter that looks correct and is not:

// Broken: this Map exists per isolate. Another isolate has its own copy.
const hits = new Map<string, number>();

export default {
  async fetch(request): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";
    const count = (hits.get(key) ?? 0) + 1;
    hits.set(key, count);
    return new Response(count > 10 ? "slow down" : "ok", {
      status: count > 10 ? 429 : 200,
    });
  },
} satisfies ExportedHandler;

The module-level Map lives in one isolate. A client sending 30 requests that land on three isolates sees three independent counters that each stop at 10, and the limit is never enforced. Moving the counter into an external database fixes the sharing but introduces a read-then-write race between concurrent requests. The problem is not where the data sits; it is that nothing guarantees a single place where the check and the update happen together.

The Core Idea: One Object per Name, Same Instance Every Time

Durable Objects solve coordination by giving each name exactly one running instance and routing every request for that name to it. The concepts page sets out three properties behind that: each object answers to a name that is unique worldwide, its storage sits with it rather than across a network, and it runs one thing at a time, the way JavaScript in a browser tab does.

Three properties follow from that model:

  1. Identity is the name. Your Worker picks a string (an API key, a room ID, a document ID) and the platform maps it to one instance. Two Workers on different continents passing the same string talk to the same object.
  2. Creation is implicit. There is no create call. The namespace API reference explains that an ID by itself creates nothing, and that objects are not built until something actually reaches them. In practice the constructor runs when the first method call on the stub arrives.
  3. Execution is single-threaded. Synchronous code inside a method cannot be interrupted by another request. Other requests can only run while your code is awaiting non-storage I/O such as fetch().

Because each object is one thread on one machine, throughput scales out rather than up: a rate limiter should be one object per API key, never one global object for all traffic.

How Do You Define and Register a Durable Object Class?

A Durable Object is an exported class that extends DurableObject from cloudflare:workers, takes ctx and env in its constructor, and exposes its public methods to Workers over RPC. The get-started guide fixes the constructor signature as (ctx: DurableObjectState, env: Env) with a required super(ctx, env) call.

import { DurableObject } from "cloudflare:workers";

export class RateLimiter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.ctx.storage.sql.exec(
      "CREATE TABLE IF NOT EXISTS hits (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)"
    );
  }

  async increment(limit: number, windowMs: number): Promise<{ allowed: boolean; remaining: number }> {
    const bucket = Math.floor(Date.now() / windowMs);

    // No await between the read and the write: nothing else can run in between.
    const row = this.ctx.storage.sql
      .exec<{ count: number }>("SELECT count FROM hits WHERE bucket = ?", bucket)
      .toArray()[0];
    const count = row ? row.count + 1 : 1;

    this.ctx.storage.sql.exec(
      "INSERT INTO hits (bucket, count) VALUES (?, ?) ON CONFLICT(bucket) DO UPDATE SET count = ?",
      bucket, count, count
    );

    return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
  }
}

The SELECT and the upsert run with no await between them, so no other request for this API key can slip in. That single fact is what the stateless version could not offer.

Registering the class takes two entries in wrangler.jsonc. The exports entry marks the class as a durable-object with sqlite storage and is what provisions the namespace on first deploy. The durable_objects.bindings entry gives the Worker a handle through env:

{
  "durable_objects": {
    "bindings": [
      { "name": "RATE_LIMITER", "class_name": "RateLimiter" }
    ]
  },
  "exports": {
    "RateLimiter": {
      "type": "durable-object",
      "storage": "sqlite"
    }
  }
}

Older examples register classes through a migrations array with new_sqlite_classes. That form is still supported for existing Workers, but exports is the current method and the two cannot coexist in one config file.

How Do You Call a Durable Object from a Worker?

A Worker reaches a Durable Object by asking the binding for a stub with getByName(name) and then calling the class’s public methods on that stub as ordinary async functions. A stub is only a local handle: calls made on it are forwarded to the one instance that owns the name.

export default {
  async fetch(request, env): Promise<Response> {
    const key = request.headers.get("x-api-key") ?? "anonymous";

    // `key` is the object's identity. Same key, same instance, everywhere.
    const stub = env.RATE_LIMITER.getByName(key);
    const { allowed, remaining } = await stub.increment(10, 60_000);

    return new Response(allowed ? "ok" : "slow down", {
      status: allowed ? 200 : 429,
      headers: { "x-ratelimit-remaining": String(remaining) },
    });
  },
} satisfies ExportedHandler<Env>;

The string passed to getByName() is the routing key. getByName(name) is shorthand for the older two-step idFromName(name) followed by get(id), which you will still see in many examples; both address the same object. Calling methods directly on the stub as RPC requires a compatibility date of 2024-04-03 or later, which any new template satisfies.

Where Does a Durable Object Store Its State?

Each Durable Object has two kinds of state: a private embedded SQLite database that survives restarts, and ordinary class fields that live only while the object is in memory. The SQLite storage API runs on the same thread as your code, so exec() hands back a SqlStorageCursor straight away with no await. Drain that cursor before the next await, using .toArray(), .one(), or a loop: a cursor left open across an await can pick up rows written in the meantime, including writes that later roll back.

A class field is the fast path. Adding one to the rate limiter shows the difference:

export class RateLimiter extends DurableObject<Env> {
  // In-memory: fast, private to this instance, gone after hibernation.
  private lastSeen = 0;

  async increment(limit: number, windowMs: number) {
    this.lastSeen = Date.now();
    // ... SQLite read and write as before (durable)
  }
}

lastSeen survives between consecutive requests, but the lifecycle documentation states that after 10 seconds with no incoming events (and no pending timers, standard-API WebSockets, or in-flight fetch()), the object hibernates and its memory is discarded. Deploys and runtime maintenance can also restart it at any time. The hits table survives all of that. Each SQLite-backed object can hold up to 10 GB on Workers Paid, per the limits page.

Should You Use Workers KV or Durable Objects?

Use Workers KV when many locations need to read the same data and it is acceptable for a write to take time to become visible everywhere; use a Durable Object when several clients must agree on the current value at the same moment. The KV consistency documentation is blunt about the trade: KV gives up consistency for speed, a change can take a minute or longer to show up in other locations, and anything that needs an atomic read and write together should use Durable Objects instead.

Workers KVDurable Objects
ConsistencyEventual; cached copies expire on a TTLStrong; one instance owns the data
Where reads happenAny location, from cacheInside the single owning instance
Read-then-write safetyNone across requestsGuaranteed with no intervening non-storage await
Write patternInfrequent writes per keyPer-object writes serialized by the runtime
Typical useConfig, feature flags, allow-listsCounters, locks, rooms, per-entity state

The storage options comparison sorts the two the same way: KV covers configuration and similar values that are read far more often than they change, while Durable Objects cover coordination between clients and storage that stays consistent per object. A rate limiter is a read-then-write counter, so it belongs in a Durable Object. A per-tenant feature flag read on every request belongs in KV.

Conclusion

Durable Objects fix coordination by removing the question of where state lives: the name you pass to getByName() selects exactly one running instance, its SQLite database sits on the same thread as its code, and a read followed by a write with no non-storage await between them cannot be interleaved. The next step is to scaffold the Worker + Durable Objects template with npm create cloudflare@latest, replace the generated class with the rate limiter above, and run npx wrangler dev to watch the count climb across requests that would otherwise never agree.

FAQs

What is the difference between Durable Objects and D1?

D1 is a managed SQLite database that your Worker queries over the network, with an HTTP API and schema migrations built in. A Durable Object's SQLite database runs on the same machine as the object's code and is reachable only from Workers through that object. Both cap at 10 GB per database on the Workers Paid plan, and both have lower caps on the free plan. Use D1 for one shared relational database; use Durable Objects for per-user or per-entity state that needs coordination.

How many requests per second can a single Durable Object handle?

A single Durable Object has a soft limit of about 1,000 requests per second, because each object runs on one thread on one machine. Past that, the runtime queues what it can and then fails the extra calls with an overloaded error. Each invocation gets 30 seconds of CPU time by default, configurable up to 5 minutes with limits.cpu_ms in Wrangler configuration. Scale out with one object per name, such as one object per API key.

Do Durable Objects work on the Workers Free plan?

Yes. Durable Objects with the SQLite storage backend are available on the Workers Free plan, capped at 1 GB per object, 5 GB of total Durable Objects storage per account, and 100 Durable Object classes. Workers Paid raises these to 10 GB per object, unlimited account storage, and 500 classes. Once an object is full, writes fail with a SQLITE_FULL error, though you can still read rows and delete them to free up space.

Do I need explicit transactions with sql.exec() in a Durable Object?

Usually not. Each call to sql.exec() already runs inside its own transaction, and reads and writes that follow one another with no await in between are committed as a single atomic batch, so the read-then-write in a rate limiter is safe as written. sql.exec() cannot run BEGIN TRANSACTION or SAVEPOINT statements. To group several statements so they all roll back if one throws, use ctx.storage.transactionSync(callback). The callback must be fully synchronous: not declared async, and returning no Promise.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.