In my previous post of Introduction to Durable
Objects , I covered
the basics of using the storage
API to persist state in Cloudflare
Workers. In this post, we’ll explore another key API provided by Durable
Objects: the WebSocket
API,
which enables us to easily create real-time, collaborative applications.
WebSockets Basics
Let’s first recap the general idea of WebSockets outside of Cloudflare. WebSockets are a standard protocol that maintains bidirectional, long-lasting connections between clients and servers. A server can send and receive messages from a client and vice versa. A simple WebSocket in node.js looks like this:
Note that we need do a bit of housekeeping when establishing the WebSocket connection (highlighted in the code). We verify that we receive the correct request headers and respond with the appropriate headers. While the implementation details aren’t crucial here, it’s important to note that this health check is necessary for WebSocket connections and will be needed in the Workers runtime as well.
Once the connection is established, we can send messages to the client
using socket.write()
, and react to incoming messages using the
socket.on('data')
callback.
A client can connect to the server using the WebSocket
constructor:
WebSockets enables real-time multiplayer apps because the server can send messages to all connected clients through the long-lived connection. In the chat app we are building, we want to broadcast message from one client to all other connected users. This is only a couple of lines for the server:
The key here is that we keep track of all connections in an array. When a message arrives, we loop through the array and send the message to each connected client.
WebSockets Server on Durable Objects
While the Edge Platform on which Durable Objects run offers a subset of Node.js APIs, the process is quite similar. We use DO instances to model a chat room or a game match. Workers send requests to the DO instance (the server), which then broadcasts messages to all other connected clients.
Let’s explore how we can migrate the Node.js example to the Edge using Durable Objects and Workers. Our goal is to build a chat app with multiple rooms. Users can send messages that are broadcast to all other users in the same room, with messages persisted and isolated at the room level. Browse the finished version here and code on Github.
Let’s begin by scaffolding an empty worker project using Hono:
Update or create the following files in the src
directory:
import { Hono } from "hono";
const app = new Hono<{ Bindings: Env }>();
app.get("/rooms/:name/ws", async (c) => {
const upgrade = c.req.header("Upgrade");
if (!upgrade || upgrade !== "websocket") {
health check return c.text("Upgrade header must be websocket", 426);
}
const name = c.req.param("name");
const id = c.env.ROOM.idFromName(name);
const stub = c.env.ROOM.get(id);
return stub.fetch(c.req.raw);
});
export * from "./room"
Let’s break this down:
Derive DO from Route
app.get("/rooms/:name/ws", handler)
defines a handler that will be
called when a GET request is sent to /rooms/<name>/ws
, where <name>
dynamic route parameter. We derive the DO instance ID from the name and
pass on the request.
Accept Connection
WebSocketPair()
returns an object containing two sockets:
where the first is the client socket and second the server socket. We
can then call this.ctx.acceptWebSocket(server)
to upgrade the
connection and also returns the client socket in the response. This is
equivalent to returning the convoluted response header in the Node.js
example.
Note that WebSocketPair
is a Cloudflare Workers runtime only API, and
is not available in standard Node.js.
Handle Message Events
webSocketMessage
and webSocketClose
are conventional methods for
handling message events, and will be automatically invoked when a
message from client is received, or the client disconnects.
WebSockets Hibernation
Despite some syntax differences, what we’ve built so far is essentially the same as the Node.js example. However, we should consider an optimization known as WebSocket Hibernation.
According to the
docs,
this is a feature that allows a DO to be removed from memory (it
“hibernates”) when it’s inactive (e.g. not in the process of executing
webSocketMessage()
) while keeping its WebSockets connected. This
reduces duration charges that would otherwise be incurred during periods
of inactivity.
Even better, ctx.acceptWebSocket()
already sets up hibernation for us.
If you establish a connection this way, the DO instance will be
automatically put into hibernation when it’s inactive.
It seems we can move forward with our chat app without any extra work to accommodate hibernation. Let’s continue by working on our session handling logic. Here’s the flow of how a user joins a chat room and sends a message:
-
User enters a room and requests a WebSockets connection via
new WebSocket()
to our server. -
When the connection is established, we request that the user immediately send us a message of type
"join"
with their username, which is used to identify the user in the chat room. After the server finished processing the join event, it will broadcast to all other users in the room that a new user has joined. -
The user can now broadcast messages to other users in the room.
Based on these requirements, it is clear that we need to store all live
connections. We can model this with a Map
, where the key is the
established socket, and the value is an object containing the registered
username.
We assume that incoming messages are JSON strings with a specific structure. For joining events, the structure is:
For chat events:
The code is becoming a bit lengthy, but the core logic is simply
mutating this.sessions
in reaction to user join and leave events. When
a chat message is received, we loop through this.sessions
and send it
to other clients.
However, there’s one caveat: any instance properties are reset when the
instance wakes up from hibernation. This means that this.sessions
will
be become an empty map and we lose track of all usernames. We also can’t
use the storage API to store usernames, as they need to be tied to their
respective sockets, which are not serializable. Luckily there are other
APIs that can help use restore the state:
-
ws.serializeAttachment(value)
keeps an arbitrary value in memory to survive hibernation. -
ws.deserializeAttachment()
retrieves the last serialized value. -
this.ctx.getWebSockets()
returns all WebSocket connections. This is not affected by hibernation and will always return the latest list of server sockets.
With these tools, we can serialize the session data in advance, and
prepare for hibernation by always reconstructing this.sessions
in the
constructor.
The first time the DO is created, nothing is different in the constructor because we haven’t serialized anything yet. But when a user joins the chat, we put the updated session data in memory to survive hibernation. When the DO wakes up, the constructor is called it will be populate all sessions with the latest data.
Displaying Previous Messages
A final requirement is that when a user connects, they should see a list
of all previous messages in the room. How we store the messages can
vary. It could be an external database accessed via a RESTful API. But
since we are using durable objects already, we can simply use the
storage
API.
One gotcha is that we want to only display historical messages when the
user is registered with a username. For this we will add another field
to Session
called blockedMessages
.
Think blockedMessages
as a queue that will be populated upon
connection, we will dequeue the messages when the user is registered.
Let’s refactor the relevant handlers to the following:
You may also want to set up an alarm handler to clean up historical messages periodically.
This wraps up our chat application. The complete code is available on Github with two additional features: a rate limiter and enhanced message types. A demo is available at https://durable-object-chat.qiushiyan.dev/.