What are Durable Objects and Why They are Needed
Cloudflare Workers are one of the most impactful serverless solutions. Each worker is replicated across Cloudflare’s global network in hundreds of cities worldwide, also called the Edge. When a user makes a request, a worker instance is spawned close to the user’s geographic location to serve the request with minimal latency.
This works great until you need to coordinate between clients. In edge deployments, requests are spread across multiple workers, which makes it hard to share and manage state. If you are building a collaborative editor, you want every edit User A makes to be reflected instantly on User B’s screen. One obvious solution is to store keystrokes in a database and have all clients constantly poll it to stay in sync. At best, this performs poorly. Moreover, we will have consistency problems, with database transactions from different users fighting each other and failing constantly.
It boils down to one problem: neither workers nor databases are designed to ensure real-time consistency. This is where Durable Objects (DO) come into play.
In contrast to workers, DO can act as a single source of truth across clients and ensure strong consistency with the following features:
-
Uniqueness: Durable Objects are literally “objects”, as they are instances of a JavaScript class. Each is uniquely identified by an ID: you call
MY_DO_CLASS.get(id)to spawn an object, and it exists in only one location in the world. All workers talk to the same DO as long as they use the same ID. -
Persistence: DO can persist state to disk storage. All workers can send messages to it, mutate the state, and get fast, consistent responses. When an object becomes idle, it is garbage collected, but its state is restored when the same ID is used again.
Configure Durable Objects
If you have used other Cloudflare products such as KV or D1, you
will be used to adding bindings with the wrangler CLI, e.g.:
wrangler create d1 <name>However, with Durable Objects, we need to add entries to the
wrangler.toml configuration file manually. Let’s generate a minimal
worker project:
pnpm create cloudflare --type=hello-world --lang=tsUpdate the following files:
name = "durable-object-starter"main = "src/index.ts"
[[durable_objects.bindings]]name = "MY_DURABLE_OBJECT"class_name = "MyDurableObject"
[[migrations]]tag = "v1"new_classes = ["MyDurableObject"]export class MyDurableObject { // DO implementation async fetch(request: Request, env: Env) { return new Response("Hello World"); }}export default { async fetch(request, env) { let id = env.MY_DURABLE_OBJECT.newUniqueId(); let obj = env.MY_DURABLE_OBJECT.get(id); return obj.fetch(request); }}
export * from "./durable-object";make sure you export the DO class from the worker entry fileLet’s break down the code:
Add the Binding
We configure the DO binding in wrangler.toml. The
durable_objects.bindings.name field will be used by the worker to
refer to the Durable Object in env, and
durable_objects.bindings.class_name will be the name of the class that
implements the Durable Object behavior. With
class_name = MyDurableObject, you must export a MyDurableObject
class from the entry point of your worker.
The [[migrations]] section is relevant when we need to add or remove
DOs in production. We won’t need it in this example but it’s a best
practice to include it.
Define DO
We define the DO class MyDurableObject in src/durable-object.ts. In
this example, it only has one method, fetch. It’s also common to see
this class defined and exported directly from src/index.ts.
Access DO in the worker
Inside the worker entry file src/index.ts, env.MY_DURABLE_OBJECT
works like a key-value namespace. To actually use the MyDurableObject
class, you create instances. To get an instance of the Durable Object,
you call env.MY_DURABLE_OBJECT.get(id), where id is a unique
identifier for the instance. Here, we are creating a new unique ID on
the fly with newUniqueId().
In practice, you will likely want to generate the ID from a string that
is meaningful to your application. MY_DURABLE_OBJECT.idFromName works
like a hash function that always returns the same ID for the same input
string. If you want a Durable Object per user, you might call
idFromName() on the user’s IP address. If you are building a chat
application with many rooms, you could call idFromName on the room’s
name.
const ip = request.headers.get("CF-Connecting-IP");const id = env.MY_DURABLE_OBJECT.idFromName(ip);const obj = env.MY_DURABLE_OBJECT.get(id)Communication Between Workers and Durable Objects
DO are designed to operate within a worker instead of being a public
interface for HTTP clients. In our starter example, our DO defines a
fetch method that returns a Response object, and our worker is
simply a proxy that creates a new DO instance and forwards the request
to it.
export default { async fetch(request, env) { let id = env.MY_DURABLE_OBJECT.newUniqueId(); let obj = env.MY_DURABLE_OBJECT.get(id); return obj.fetch(request); },};Handling HTTP requests is a common pattern for DO. The flow is:
-
the worker takes a request and computes the ID of the DO instance that should handle the request
-
the worker creates or gets the instance and calls its
fetchmethod -
the DO instance takes the request, does something with it, and returns a response
-
the worker gets the response and returns it to the client
You can think of Durable Objects as a kind of “unique inner worker” called by other, outer workers.
It’s also possible to define non-fetch methods: they become RPC methods that workers and other Durable Objects can call.
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { private value: number;
constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.value = 0 }
async increment(amount = 1) { this.value += amount; return this.value; }}export default { async fetch(request, env) { let url = new URL(request.url); let name = url.searchParams.get("name"); let id = env.COUNTERS.idFromName(name); let response = await durableObjectStub.increment(); return new Response(`Counter incremented to ${response}`); }}In the example above, the worker calls the increment RPC method on the
DO, which modifies its internal state and returns the new value. The
worker wraps the value in a response.
Persist State with the storage API
In the RPC example above, we are using the property value to store the
counter value. This is not ideal if we want the value to be persisted.
After a period of inactivity, the DO will be garbage collected and the
value will be lost. Although you can get the same object with
MY_DURABLE_OBJECT.get(id), the constructor method will be called
again and value will be reset to 0.
Durable Objects can access persistent storage via
this.ctx.storage. This is a key-value store that is scoped to the
DO instance. It provides interfaces such as get, put, delete, and
list, and each operation is implicitly wrapped in a transaction.
Let’s refactor our counter example to use the storage API.
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { async increment(amount = 1) { let value: number = (await this.ctx.storage.get("value")) || 0; value += amount; await this.ctx.storage.put("value", value); return value; }}export default { async fetch(request, env) { let url = new URL(request.url); let name = url.searchParams.get("name");
if (!name) { return new Response( "Select a Durable Object to contact by using" + " the `name` URL query string parameter, for example, ?name=A" ); }
let id = env.COUNTERS.idFromName(name); let stub = env.COUNTERS.get(id);
let count = null; switch (url.pathname) { case "/increment": count = await stub.increment(); break; default: return new Response("Not found", { status: 404 }); }
return new Response(`Durable Object '${name}' count: ${count}`); }}Build a Counter Service with Hono
Let’s turn our counter example into a full-fledged API service. We will use Hono to scaffold routes for our worker and call the DO for storage. Hono is a trending web framework that is fully compatible with the Edge platform. Scaffold a worker with Hono using the following command:
pnpm create cloudflare --framework=honoAfter the project is created, src/index.ts contains the following
code:
import { Hono } from "hono";const app = new Hono();
app.get("/", (c) => c.text("Hello world, this is Hono!!"));
export default app;If you have used Express before, this looks very similar. app is
simply an object with a fetch method that routes the request to a
handler; we just aren’t writing fetch directly.
app.get("/", handler) calls the handler when a GET request is made to
/.
Next, we will define the Counter DO.
[[durable_objects.bindings]]name = "COUNTER"class_name = "Counter"
[[migrations]]tag = "v1"new_classes = ["Counter"]import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject { async increment(amount = 1) { let value = (await this.ctx.storage.get<number>("value")) || 0; value += amount; await this.ctx.storage.put("value", value); return value; }
async decrement(amount = 1) { let value = (await this.ctx.storage.get<number>("value")) || 0; value -= amount; await this.ctx.storage.put("value", value); return value; }
async getCounterValue() { const value = (await this.ctx.storage.get<number>("value")) || 0; return value; }
}Most of the code is self-explanatory: we’ve added RPC methods to the
Counter DO to decrement and get the counter value.
The remaining step is to add routes to the worker and call the
appropriate DO methods. /<name>/increment and /<name>/decrement will
be the endpoints to increment and decrement the counter, respectively.
We use <name> to derive the Durable Object ID so each counter is
isolated from the others. Don’t forget to re-export the Counter class.
const app = new Hono<{ Bindings: CloudflareBindings }>();Annotate the env object
function getCounter(c: Context) { const id = c.env.COUNTER.idFromName(c.req.param("name")); const obj = c.env.COUNTER.get(id); return obj;}
app.get("/:name/value", async (c) => { const obj = getCounter(c); const value = await obj.getCounterValue(); return c.json({ value });});
app.post("/:name/increment", async (c) => { const obj = getCounter(c); const value = await obj.increment(); return c.json({ value });});
app.post("/:name/decrement", async (c) => { const obj = getCounter(c); const value = await obj.decrement(); return c.json({ value });});
export * from "./counter";What is shown here is a simplified version. The actual service adds a rate limiter to prevent excessive requests from the same IP address; the rate limiter is also a DO. Browse the full code on GitHub.
We can access bindings via c.env in the Hono app. To get
autocompletion, we also add the generic type
{ Bindings: CloudflareBindings }. CloudflareBindings is generated by
running pnpm cf-typegen.
The worker and DO can now be deployed with pnpm deploy. I have set up
a simple UI to test the endpoints. The cards below represent DOs named
card-1 and card-2. Clicking the plus or minus button sends requests
to /card-1/increment and /card-2/decrement, respectively.
Other Notes
-
Durable Objects support the Workers runtime WebSocket API and can become a WebSocket server (see https://developers.cloudflare.com/durable-objects/api/websockets/). I will write a separate post later to build a chatroom application with DO + WebSocket.
-
Another important DO feature is the
alarmAPI, which schedules a wake-up call for the DO at a specific time. See https://developers.cloudflare.com/durable-objects/api/alarms/ for more information.