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.
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.:
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:
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.
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.
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.
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:
After the project is created, go to src/index.ts
and we can see the
following code:
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.
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.