Managing State in Cloudflare Worker with Durable Objects

A powerful infrastructure for building stateful, realtime and collaborative applications.

8 min read
376 views
Last modified: 11/26/2024

What are Durable Objects and Why They are Needed

Cloudflare workers are one of the most impactful serverless solutions. Each worker is replicated over the Cloudflare global network in hundreds of cities worldwide, also called the Edge. When a user makes a request, a worker instance will be spawned close to the user’s geographic location to serve the request with minimal latency.

Cloudflare network where each circle represents an edge location in which workers can be spawned
Cloudflare network where each circle represents an edge location in which workers can be spawned

This works great until you need to coordinate between clients. Edge deployments requests are spread across multiple workers and it will be hard to share and manage state. If you are building a collaborative editor, you want every edit User A makes is reflected instantly on User B’s screen. One obvious solution is to store keystrokes in a database and have all clients constantly pulling from it to keep the them in sync. This will at best, have poor performances. Moreover, we will have consistency problems with database transactions from different users fighting each other and fails constantly.

It boils down to one problem: both workers and databases are not designed ensure realtime 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. They are 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 will talk to the same DO so long as they are using the same ID.

  • Persistence: Do can manage states as persistent disk storage. All workers can send messages to it, mutate the state, and get fast, consistent responses. When an object becomes idle, it will be garbage collected, but the state will be 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 using the wrangler cli, e.g.:

wrangler create d1 <name>

However, with durable objects, we need to manually add entries to the wrangler.toml configuration file. Let’s generate a minimal worker project with the command:

pnpm create cloudflare --type=hello-world --lang=ts

Go into each of the following files and update the code as:

name = "durable-object-starter"
main = "src/index.ts"
[[durable_objects.bindings]]
name = "MY_DURABLE_OBJECT"
class_name = "MyDurableObject"
[[migrations]]
tag = "v1"
new_classes = ["MyDurableObject"]

Let’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 a class you implement 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 we need to add or remove DO 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 have 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 based on a string that is meaningful to your application. MY_DURABLE_OBJECT.idFromName works like a hash function that always returns the same id given the same string as input. If you want to create a durable object on a per-user basis, you will probably 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)

Create ip-scoped durable object instances

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);
},
};

worker forwards request to durable object

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 fetch method

  • 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

This way, you can think of durable objects as a kind of “unique inner worker” that are 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;
}
}

In the example above, 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.

Note

RPC (Remote Procedure Call) is a standard communication protocol for Cloudflare Workers and is not unique to durable objects. See docs for more information.

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 gain access to a 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;
}
}

Build a Counter Service with hono

Let’s enhance our counter example by turning it 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. A worker can be scaffolded with hono using the following command:

pnpm create cloudflare --framework=hono

After the project is created, go to src/index.ts and we can see 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 the a handler, it’s just that we are not 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"]

Most of the code is self-explanatory, we’ve enhanced the Counter DO with RPC methods 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 will use <name> to derive the durable object ID so each counter is isolated from others. And don’t forget to re-export the Counter class.

src/index.ts
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 full code on Github.

We can access bindings via c.env in the hono app. To get autocompletion we are also adding a generic type { Bindings: CloudflareBindings }. CloudflareBindings is a generated type produced by running pnpm cf-typegen.

The worker and DO can now be readily deployed using pnpm deploy. I have set up a simple UI to test the endpoints. Each card below represents a DO with name card-1 and card-2. Clicking on the plus or minus button will send requests to /card-1/increment and /card-2/decrement respectively.

counter-1

counter-2