I was using Cloudflare Durable Objects to build a real-time app earlier last month and was running into a bug with my WebSocket connections - (I was vibe coding a bit too quickly). So I built a simple real-time chat app to figure it out (no better way to debug a problem).
My issue was that my WebSockets were seemingly disconnecting after 10 seconds, and I would no longer be able to send messages from one client to another. Basically:
- Chat worked for ~10 seconds, then messages stopped sending
- No errors on the client
- Durable Object seemed to be “resetting”
Turns out that I was using the WebSocket API for Hibernation, which allows Durable Objects to go to sleep while WebSocket clients are connected (but requires a bit more work to get it working within an app). Here’s why.
WebSockets API vs WebSockets Hibernation API
A bit of history on WebSockets API vs WebSockets Hibernation API: Cloudflare Durable Objects are really powerful: they are a core primitive for stateful compute within Cloudflare’s developer platform. One of those uses for stateful compute is, you guessed it, WebSockets and real-time applications.
Originally, Durable Objects supported basic WebSockets, which ensured that the Durable Object was kept in memory as long as one WebSocket client was connected. This incurred duration charges even during periods of inactivity, so the WebSocket Hibernation API was introduced (back in September of 2023, alongside other announcements making Workers no longer bill for idle time).
With the WebSocket Hibernation API, the WebSocket client can stay connected to Cloudflare’s network, while the Durable Object gets to be removed from memory, drastically improving the efficiency of such a solution. Pretty cool if you ask me (but I’m biased - I work at Cloudflare on the developer platform). Here’s what that looks like:
Alright, so we understand the difference between the basic WebSocket API and the WebSocket Hibernation API. So what was the problem that I was running into?
Hibernation = wiping Durable Object in-memory state
My problem actually didn’t lie in my usage of the Durable Objects Hibernation WebSocket API at all. In my chat application, I was storing a list of rooms that WebSocket clients were in, as many real-time applications would. All was going well for the first 10 seconds - and if I were using the basic WebSocket API this would be fine, as the map of room to WebSocket would be kept in the Durable Object memory until the last client disconnected.
But I was using Durable Objects Hibernation. So I needed to be a bit more careful with the Durable Object’s lifecycle and state.
You see, when the Durable Object “hibernates”, it gets removed from memory, wiping its in-memory state. You might say, “well yeah, obviously”, but at this point I was still new to Durable Objects and the Durable Object lifecycle wasn’t very well documented across the docs (that’s since been improved quite a bit).
The Fix: serializeAttachment & deserializeAttachment
So, once I realized how hibernation worked, I needed to persist the map of chat room to WebSocket client before the Durable Object hibernated, and restore the map after the Durable Object was restored. To do so, there are 2 methods of the Durable Object Hibernation WebSocket API, WebSocket.serializeAttachment()
and WebSocket.deserializeAttachment()
, that are intended for this purpose. They complement the ctx.getWebSockets()
method that is used to get all WebSockets connected to a Durable Object.
So, this was the fix to my code. When WebSocket clients were connected to my Durable Object and added to a room, I needed to persist which room the WebSocket was in using serializeAttachment as such:
// Durable Object
export class WebSocketHibernationServer extends DurableObject {
private sessions = new Map<WebSocket, UserSession>();
//...
private async handleJoinRoom(ws: WebSocket, data: any): Promise<void> {
const { username, room, userId } = data;
// Store user session
this.sessions.set(ws, {
username,
room,
userId,
joinedAt: Date.now(),
});
ws.serializeAttachment({
username,
room,
userId,
joinedAt: Date.now(),
});
// Notify other users that the user has joined
}
// Rest of Durable Object code
}
That way, when the Durable Object is removed from memory, we can recover the data for the WebSocket to room map. This must be done in the constructor of the Durable Object, which is called when the Durable Object is restored to memory, as such:
// Durable Object
export class WebSocketHibernationServer extends DurableObject {
// Stores active WebSocket client metadata, keyed by WebSocket
private sessions = new Map<WebSocket, UserSession>();
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.ctx = ctx;
console.log(`WebSocketHibernationServer initialized with ID: ${ctx.id}`);
console.log("Restoring existing WebSocket sessions...");
console.log(
`Found ${this.ctx.getWebSockets().length} existing WebSocket connections.`
);
// This function gets the data for each WebSocket connected to the Durable Object
this.ctx.getWebSockets().forEach((webSocket) => {
let meta = webSocket.deserializeAttachment();
this.sessions.set(webSocket, {
...meta,
});
});
}
// Rest of Durable Object code
}
And with this, we now have Durable Objects properly handling WebSocket connections while hibernating during periods of inactivity.
So, to recap:
- WebSocket Hibernation lets Durable Objects unload from memory without dropping client connections.
- You must persist per-client state using
serializeAttachment()
if you want to recover it later. - The constructor is your hook for restoring WebSocket state with
deserializeAttachment()
. - You can access the connected WebSockets with
ctx.getWebSockets()
If you want to check out the code for the simple chat room app that I wrote to understand how this works, check it out here: https://github.com/thomasgauvin/react-router-hono-durable-objects-chatrooms. I also recorded a quick demo on Twitter about this: https://x.com/thomasgauvin/status/1936094327584809114/video/1.