Apr 11, 2021

The Future of Multiplayer: Cloudflare Durable Objects

A lot of today's applications offer some kind of real-time collaboration feature. Also referred to as multiplayer features, live chat, seeing your colleague's cursors, or working on a document at the same time, everything around improving collaboration has been on the rise recently.

Building synchronized features isn't straightforward, though. You'll need some coordinating instance, and if you're working on collaborative document editing, looking into Conflict-Free Replicated Data Types (CRDTs) and Operational Transformation (OT) might be necessary.

Last fall, Cloudflare introduced a new feature for their serverless edge compute platform, Cloudflare Workers: Durable Objects.

Dubbed a "new approach to stateful serverless", Durable Objects are regular JavaScript objects with instances that exist only once in the world at a time. Imagine a chat application and one Durable Object instance for each chat room. All messages are kept in this object, and users can send requests or connect to the room using WebSockets.

Durable objects are also, well, durable, meaning that, using the transactional and strongly consistent key-value storage API, their state is persisted on disk. If not in use, durable objects are frozen and can later be re-activated on-demand. In case the usage shifts to another region, objects may be migrated across Cloudflare points of presence without any manual intervention.

Each object is backed by a single thread and only a single thread, so multiple objects scale perfectly well, but single objects are constrained to similar limits the regular Workers face.

You can't connect to Durable Objects directly but have to forward an HTTP request or WebSocket from an initial Worker deployment, and any Worker that knows the Durable Object ID can send messages to it, with every message being delivered to the same place.

On the last day of March, Cloudflare announced that Durable Objects are now in open beta, accessible for all accounts subscribed to the paid Workers plan.

Cloudflare has created an amazing collection of resources in its documentation, and I've collected some of the details I found most important below:

Creating a Durable Object class

The first step to working with Durable Objects is writing a class definition for your object.

export class DurableObjectExample {
  constructor(state, env) {}

  async fetch(request) {
    return new Response('Hello World');
  }
}

As with regular workers, Durable Objects can listen for incoming HTTP requests using a fetch method. Via request headers, body, method, or URL, worker deployments can pass information to a Durable Object.

Note that incoming requests aren't necessarily routed over the internet, Cloudflare has its own infrastructure around serving requests across processes and sandboxes, and protocols other than HTTP might be supported in the future.

Persistent Storage API

export class DurableObjectExample {
  constructor(state, env) {
    this.state = state;
  }
  async fetch(request) {
    let ip = request.headers.get('CF-Connecting-IP');
    let data = await request.text();
    let storagePromise = this.state.storage.put(ip, data);
    await storagePromise;
    return new Response(ip + ' stored ' + data);
  }
}

Using the state variable passed to the object constructor, you can access the persistent on-disk storage API. Each method uses a transaction, such that its results are atomic and isolated from all other storage operations, even when accessing multiple key-value pairs.

While each object is single-threaded, I/O and other delays may cause incoming requests to be processed out of order, not necessarily guaranteeing serial execution. This could be managed with per-object locks.

If you require to access the storage API more than once, you can wrap multiple calls in a manual transaction, which will either fail or succeed with all included operations. The following example from the docs illustrates how a key can be written to storage if, and only if, its current value matches the given request header.

export class DurableObjectExample {
  constructor(state, env) {
    this.state = state;
  }
  async fetch(request) {
    let key = new URL(request.url).host;
    let ifMatch = request.headers.get('If-Match');
    let newValue = await request.text();
    let changedValue = false;
    await this.state.storage.transaction(async txn => {
      let currentValue = await txn.get(key);
      if (currentValue != ifMatch && ifMatch != '*') {
        txn.rollback();
        return;
      }
      changedValue = true;
      await txn.put(key, newValue);
    });
    return new Response('Changed: ' + changedValue);
  }
}

Transactions are run in serializable isolation level and can fail if concurrent requests cause a conflict.

Transactions are retried once by rerunning the provided function before returning an error. To avoid transaction conflicts, don't use transactions when you don't need them, don't hold transactions open any longer than necessary, and limit the number of key-value pairs operated on by each transaction.

With careful use of promises, you could serialize operations in your live object so that there's no possibility of concurrent storage operations. We provide the transactional interface as a convenience for those who don't want to do their own synchronization.

In-Memory State

Variables in a Durable Object instance will be kept in memory as long as the object is not evicted and frozen (i.e. if no longer in use). Variables and state of new object instances are usually created from the on-disk storage.

WebSockets in Durable Objects

Cloudflare Workers can now act as WebSocket clients and servers and can be combined with Durable Objects to subscribe clients to the same single source of truth. Without Durable Objects, you could not route events and changes to the Worker serving the WebSocket, so you can forward WebSockets to the Durable Object.

For more details about how WebSockets can be used in Workers and Durable Objects, check out the chat example Cloudflare provided.

Communicating with Durable Objects

To forward requests and WebSockets from one worker instance to a Durable Object, you need to create a binding to that object at build time, similar to how KV namespaces work.

In your fetch handler, you can now access or instantiate a Durable Object with

export default {
  async fetch(request, env) {
    let id = env.EXAMPLE_CLASS.idFromName(new URL(request.url).pathname);
    let stub = await env.EXAMPLE_CLASS.get(id);
    let response = await stub.fetch(request);
    return response;
  }
};

This will generate an ID given the path name, the same route will always end up with the same ID, and either fetch the existing Durable Object instance or create a new one. Due to the nature of distributed systems, the runtime needs to check if another room with the same name was generated concurrently at the other end of the world, which may take some time. This is reduced when using system-generated IDs (env.EXAMPLE_CLASS.newUniqueId()), which requires you to keep track of the generated ID.

You can also limit Durable Object instances to a specific jurisdiction,

let id = OBJECT_NAMESPACE.newUniqueId({ jurisdiction: 'eu' });

will make sure that the new object will only exist, run, and persist data within the European Union. This is extremely helpful for compliance with regulations such as the GDPR.

Uploading a Durable Object

After configuring your project, you can run

wrangler publish --new-class DurableObjectExample

to publish the Durable Object class for the first time. When using the modules syntax, make sure to declare a main module that exports all Durable Objects and event handlers. For subsequent deployments, you can just run

wrangler publish

To include the Durable Object in your worker, add the following to your wrangler.toml file

[durable_objects]
bindings = [
	{
		name = "EXAMPLE_CLASS",
		class_name = "DurableObjectExample"
	}
]

If your class is part of a different Worker, you need to specify the script_name argument.

Early Limitations

Data might be lost, so you should make sure not to store mission-critical data in Durable Objects for the time being. Global uniqueness is only enforced at the time of receiving a request, which might lead to another instance being created at the same time. Durable Objects are also not migrated to other locations after initial creation, automatic migration will be added in the future. Performance will also increase over time.


That is it for the introductory post on Durable Objects, expect more content on this coming soon!