Hibernating WebSockets in a Durable Object: idle rooms cost nothing

ctx.acceptWebSocket(server) parks the socket in the runtime; the object is evicted between messages and rebuilt on the next one, so state lives in storage.

Durable Objects

· Chapter

56

·

3

min read

The answer. A WebSocket server needs something that remembers who is connected, and a Durable Object is that something: one object per room, every socket for the room accepted by it, broadcast by iterating the sockets it holds. The Hibernation API is what makes it affordable. Instead of server.accept() plus event listeners (which keep the object in memory and billed for as long as anyone is connected), call this.ctx.acceptWebSocket(server) and implement webSocketMessage, webSocketClose and webSocketError as class methods. The runtime then evicts the object when nothing is happening while the sockets stay open; the next message re-runs the constructor and delivers to the handler. Anything you need across that gap goes in storage or in a per-socket attachment (ws.serializeAttachment()), never in a class field.

The pattern.

export class Room extends DurableObject<Env> {
  async fetch(request: Request) {
    if (request.headers.get("upgrade") !== "websocket") return new Response("expected websocket", { status: 426 });
    const pair = new WebSocketPair(); const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server, [request.headers.get("x-user") ?? "anon"]);   // tags are queryable later
    server.serializeAttachment({ joinedAt: Date.now() });
    return new Response(null, { status: 101, webSocket: client });
  }
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const text = typeof message === "string" ? message : new TextDecoder().decode(message);
    this.ctx.storage.sql.exec("insert into msgs (body, at) values (?, ?)", text, Date.now());
    for (const peer of this.ctx.getWebSockets()) if (peer !== ws) peer.send(text);
  }
  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) { ws.close(code, reason); }
}

Watch out.

  • getWebSockets() after wake-up returns the parked sockets with their attachments; a Map of sockets you built in the constructor is empty.
  • Outgoing pings from the object keep it awake and billed; let the runtime's auto-response (setWebSocketAutoResponse) answer client pings instead.
  • Messages are capped at 1 MiB and the Worker in front must check the Upgrade header before delegating, or plain GETs open rooms.

Related: durable-object-one-instance-per-id-with-sqlite · durable-object-alarms-are-per-object-timers · realtime-postgres-changes-vs-broadcast-from-triggers