DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

WebSocket Coordination With Durable Objects for a Streaming Chat

A chat connection spends almost all of its life idle. The WebSocket Hibernation API exists so that idle time is free, and the price of that is that your object is evicted from memory while the socket stays open. Every design decision here follows from that trade.

What hibernation actually does

Cloudflare documents the behaviour precisely: when the object is idle it is evicted from memory while clients remain connected, in-memory state is reset, the constructor re-runs when the next event arrives, and billable duration in GB-seconds does not accrue during hibernation.

Read that as three separate consequences. First, any instance field you set is gone — not stale, gone — so a Map of connection metadata on this cannot be relied upon. Second, the constructor is not a one-time initialiser; it runs again, so it must be idempotent and it must be cheap. Third, an in-flight ReadableStream held in a field cannot survive, which rules out the obvious design of starting a model stream in one message handler and piping it in another.

The alternative — ws.accept() rather than the hibernation API — keeps the object resident and keeps your fields, and you pay duration charges for every second the socket is open whether anything is happening or not. For a chat application where a user types for ten seconds out of every ten minutes, that difference is most of the bill.

Accepting the socket

The upgrade happens inside the Durable Object’s fetch. You create a WebSocketPair, hand the server half to ctx.acceptWebSocket() rather than calling accept() on it, and return the client half with status 101.

import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject<Env> {
  async fetch(request: Request): Promise<Response> {
    if (request.headers.get("Upgrade") !== "websocket") {
      return new Response("expected websocket", { status: 426 });
    }

    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    // acceptWebSocket, not server.accept() — this is what enables hibernation
    this.ctx.acceptWebSocket(server, ["room:general"]);
    server.serializeAttachment({ joinedAt: Date.now() });

    return new Response(null, { status: 101, webSocket: client });
  }
}
Enter fullscreen mode Exit fullscreen mode

The second argument to acceptWebSocket is an array of tags. They are how you find a subset of connections later, because after hibernation you have no other record of which socket is which — which brings us to the handlers.

The handler methods that replace onmessage

With hibernation you do not attach event listeners. You implement methods on the class and the runtime calls them, waking the object if it was evicted. Cloudflare documents webSocketMessage(ws, message), webSocketClose(ws, code, reason, wasClean) and webSocketError(). The socket that received the event is passed in as the first argument, which is the mechanism that makes the statelessness workable: you never have to have remembered it.

this.ctx.getWebSockets() returns the currently connected sockets, optionally filtered by tag, and it works after an eviction because the runtime holds them, not your object. That method plus the ws argument are the complete replacement for the connection registry you would otherwise have kept in a field.

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    if (typeof message !== "string") return;
    const { text } = JSON.parse(message) as { text: string };

    // broadcast the user's message to everyone else in the room
    for (const peer of this.ctx.getWebSockets("room:general")) {
      if (peer !== ws) peer.send(JSON.stringify({ from: "user", text }));
    }

    await this.replyWithModel(ws, text);
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string) {
    ws.close(code, reason);
  }
Enter fullscreen mode Exit fullscreen mode

Streaming a model reply into the socket

The stream has to be produced and consumed entirely within one handler invocation. That is the direct consequence of hibernation resetting in-memory state: you cannot start it here and finish it there. In practice this is not a restriction, because the object stays resident while an event handler is running.

  private async replyWithModel(ws: WebSocket, prompt: string) {
    const stream = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [{ role: "user", content: prompt }],
      stream: true,
    });

    const reader = stream.getReader();
    const decoder = new TextDecoder();
    let partial = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      partial += decoder.decode(value, { stream: true });
      const lines = partial.split("\n");
      partial = lines.pop() ?? "";

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const payload = line.slice(6).trim();
        if (payload === "[DONE]") continue;
        try {
          const event = JSON.parse(payload);
          if (event.response) {
            ws.send(JSON.stringify({ from: "assistant", delta: event.response }));
          }
        } catch {
          // partial frame, wait for the rest
        }
      }
    }

    ws.send(JSON.stringify({ from: "assistant", done: true }));
  }
Enter fullscreen mode Exit fullscreen mode

The explicit terminal message matters. SSE has [DONE]; a WebSocket has no framing convention of its own, so the client cannot distinguish “the model finished” from “the next chunk is slow” unless you tell it. The same partial-line handling as the streaming page describes applies here, because network chunks still do not align to frames.

Cloudflare documents a 32 MiB ceiling on received WebSocket messages and a CPU budget of 30 seconds per incoming message on Paid — the budget resets with each message rather than being shared across the connection’s lifetime, which is why a long stream inside one handler is fine.

Single-threaded is not one-at-a-time

A Durable Object runs one piece of JavaScript at a time, which is why you never need a mutex around a field. It does not follow that one event finishes before the next one starts. Execution yields at every await, and a second webSocketMessage can begin while the first is still waiting on the model. Cloudflare’s input gates protect storage operations specifically — they stop events being delivered while a storage call is in flight — and a twenty-second await on env.AI.run() is not a storage call.

The replyWithModel loop above is therefore exposed to a real bug, and it is one that only appears when a user is impatient. Send a second message before the first reply finishes and both loops are alive at once, both calling ws.send() on the same socket. The client receives two answers spliced together token by token, which reads as the model producing nonsense.

There are two fixes and they are not equivalent. The cheap one is to tag every delta with the id of the reply it belongs to, so the client can assemble two streams into two messages. That is the right answer for a UI that wants to show both.

  private chain: Promise<void> = Promise.resolve();

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    if (typeof message !== "string") return;
    const { text } = JSON.parse(message) as { text: string };

    // serialise replies per object: each waits for the previous one
    this.chain = this.chain.then(() => this.replyWithModel(ws, text));
    await this.chain;
  }
Enter fullscreen mode Exit fullscreen mode

The stricter one is to serialise, as above, by chaining each reply onto the previous promise. Note what this costs: the second message now waits for the first reply to finish completely, so a user who sends three messages quickly waits three generations. And because the field chain lives in memory, it is reset by hibernation — which is safe here only because hibernation cannot happen while a handler is running, so a reset chain is always an idle chain.

For a room object shared by many users, neither fix is really enough: one member’s slow generation occupies the object’s attention for the whole time. At that point the model call belongs outside the object entirely, in a Worker or a queue consumer that calls back into the object with finished text, leaving the object doing only what it is uniquely good at — holding the sockets and ordering the writes.

State that survives eviction

For the small amount of per-connection state that must survive hibernation, Cloudflare provides ws.serializeAttachment(value) and ws.deserializeAttachment(), with a documented maximum of 16,384 bytes. This is the right place for a user id, a room name, a subscription tier — the things you need in order to know who is on the other end after the constructor has re-run.

It is the wrong place for a transcript. 16 KB is not much and an attachment is not queryable; conversation history belongs in the object’s SQL storage, where it is rows you can select from and where the 10 GB per-object figure applies rather than 16 KB.

One more documented convenience worth knowing: setWebSocketAutoResponse() lets the runtime answer a ping message without waking the object at all. If your client sends a keepalive every thirty seconds — and most do — then without this every keepalive is a wake-up, and hibernation saves you nothing.

The 16,384-byte attachment limit, the 32 MiB message ceiling and the per-message CPU budget are Cloudflare’s documented figures at the time of writing. See Cloudflare’s WebSocket API reference for current values.

Related

Top comments (0)