Compute · Durable Objects

Real-time coordination: WebSockets with Durable Objects

After this lesson you'll be able to build a Durable Object that accepts and broadcasts WebSocket messages using the Hibernation API, so you're not paying compute charges for connections that are just sitting open.

The previous lesson introduced Durable Objects as single-instance actors with their own storage. WebSockets are the case that motivated a lot of that design: a chat room, a multiplayer game session, or a collaborative document all need one process that every connected client talks through, so that ordering and shared state stay consistent. A plain Worker can't hold a persistent connection open across requests — a Durable Object can, because it's a long-lived object identity, not a stateless request handler.

The problem this lesson actually solves. If you accept a WebSocket the naive way, the Durable Object has to stay resident in memory for as long as the socket is open, and you're billed for that time even when nobody's sent a message in twenty minutes. The WebSocket Hibernation API exists specifically to break that link between "connection open" and "object billed as active."

How it works

Accepting a WebSocket inside a Durable Object

A client connects to a Worker route with a normal Upgrade: websocket request. The Worker forwards that request to a Durable Object instance (routed by whatever ID makes sense — e.g. the room name). Inside the Durable Object's fetch(), you create a WebSocketPair, keep one end, and return the other end to the client:

export class ChatRoom {
  constructor(ctx, env) {
    this.ctx = ctx;
  }

  async fetch(request) {
    const upgradeHeader = request.headers.get("Upgrade");
    if (upgradeHeader !== "websocket") {
      return new Response("Expected a WebSocket upgrade", { status: 426 });
    }

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

    // Hibernatable accept — this is the whole difference.
    this.ctx.acceptWebSocket(server);

    return new Response(null, { status: 101, webSocket: client });
  }
}

The key line is this.ctx.acceptWebSocket(server) instead of the standard server.accept(). Calling .accept() uses the normal WebSocket API and keeps the Durable Object pinned in memory for the connection's entire lifetime — every event listener you attach only exists in that in-memory instance. acceptWebSocket() registers the socket with the runtime instead, so Cloudflare can evict the object from memory between messages and reconstruct it on demand.

Handling messages after hibernation

Because the object can be evicted, you don't attach ws.addEventListener("message", ...) handlers. Instead, you define lifecycle methods directly on the Durable Object class, and the runtime calls them — re-running your constructor first if the object had hibernated:

export class ChatRoom {
  constructor(ctx, env) {
    this.ctx = ctx;
  }

  async fetch(request) {
    // ...upgrade handling as above...
  }

  async webSocketMessage(ws, message) {
    // Broadcast to every other connected socket in this room.
    for (const socket of this.ctx.getWebSockets()) {
      if (socket !== ws) {
        socket.send(message);
      }
    }
  }

  async webSocketClose(ws, code, reason, wasClean) {
    ws.close(code, reason);
  }

  async webSocketError(ws, error) {
    console.error("WebSocket error:", error);
  }
}

this.ctx.getWebSockets() returns every socket currently attached to this Durable Object instance — including ones accepted in a previous "life" of the object, before it hibernated and came back. That's the trick that makes broadcast-to-a-room work correctly even though no single in-memory instance necessarily saw every connection get established.

Per-connection metadata: serializeAttachment

Since in-memory instance variables vanish on hibernation, you can't stash a username or role on this and expect it to survive. Attach it to the socket instead, using structured-clone-compatible data (capped at 16 KiB per attachment):

async fetch(request) {
  const { username } = await parseSession(request);
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair);

  this.ctx.acceptWebSocket(server);
  server.serializeAttachment({ username, joinedAt: Date.now() });

  return new Response(null, { status: 101, webSocket: client });
}

async webSocketMessage(ws, message) {
  const { username } = ws.deserializeAttachment();
  const payload = JSON.stringify({ username, message });
  for (const socket of this.ctx.getWebSockets()) {
    socket.send(payload);
  }
}

Alarms: a related but separate feature

Durable Objects also expose an alarm API for scheduling future work — useful alongside WebSocket coordination for things like "close the room if it's been empty for 10 minutes" or periodic cleanup. this.ctx.storage.setAlarm(timestamp) schedules a single future call to an alarm() method you define; getAlarm() reads it back, deleteAlarm() cancels it. Like hibernation-mode WebSocket handlers, alarm() can fire after the object has hibernated or been evicted — the runtime reconstructs the object first.

async webSocketClose(ws, code, reason, wasClean) {
  if (this.ctx.getWebSockets().length === 0) {
    // No one left in the room — check back in 10 minutes.
    await this.ctx.storage.setAlarm(Date.now() + 10 * 60 * 1000);
  }
}

async alarm() {
  if (this.ctx.getWebSockets().length === 0) {
    await this.ctx.storage.deleteAll(); // room is empty, clean up
  }
}

Alarms have guaranteed at-least-once execution, retried with exponential backoff (starting at 2 seconds, up to 6 retries) if your handler throws. That's a deep enough feature to deserve its own lesson later — the point here is just that it composes naturally with hibernating WebSocket rooms for lifecycle management.

Pitfall: in-memory state does not survive hibernation. If you keep a room's message history, connection count, or a Map of usernames in an instance property (this.messages = []), it will silently reset to whatever the constructor sets whenever the object hibernates and wakes back up — which can happen between any two messages once nobody's actively chatting. Anything that needs to survive must go into this.ctx.storage (or serializeAttachment for per-socket data), not a plain class field. This is the single most common bug when porting a "keep everything in a JS object" WebSocket server to Durable Objects.

Pricing

Durable Objects billing (paid plan) has two compute-related components plus storage, per the pricing page:

MeterIncludedAdditional
Requests (HTTP, RPC, WebSocket messages, alarms)1,000,000 / month$0.15 / million
Duration (GB-s, all 128 MB billed while active)400,000 GB-s / month$12.50 / million GB-s
Storage (SQLite-backed, stored data)5 GB-month$0.20 / GB-month

Free plan limits are 100,000 requests/day and 13,000 GB-s duration/day. The mechanism this lesson is built around matters directly here: billable duration does not accrue while a Durable Object is hibernating, so a chat room with long quiet periods between messages is billed close to zero for the idle time, not for the whole time the socket happens to stay open. Verify current figures against the live pricing page before estimating cost for a real project — Cloudflare has changed this pricing model before (storage billing for SQLite-backed objects begins in January 2026, per the docs).

Use cases

Worked example: a minimal chat room

Putting it together — a Worker that routes to a per-room Durable Object, and the Durable Object itself:

// worker.js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const roomName = url.searchParams.get("room") || "lobby";
    const id = env.CHAT_ROOM.idFromName(roomName);
    const stub = env.CHAT_ROOM.get(id);
    return stub.fetch(request);
  },
};

// chat-room.js
export class ChatRoom {
  constructor(ctx, env) {
    this.ctx = ctx;
  }

  async fetch(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);
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws, message) {
    const peers = this.ctx.getWebSockets();
    for (const peer of peers) {
      if (peer !== ws) peer.send(message);
    }
  }

  async webSocketClose(ws) {
    ws.close();
  }
}
# wrangler.toml
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]

Connect two clients to wss://your-worker.example.com/?room=lobby and messages sent by one appear on the other — with the Durable Object free to hibernate between messages the whole time.

Primary source

Durable Objects: WebSockets (best practices) is Cloudflare's own guide to the Hibernation API — it's the canonical explanation of why acceptWebSocket() exists and how it differs from the standard accept flow. For pricing specifically, see Durable Objects pricing, which is the page to re-check since Cloudflare notes storage billing changes taking effect in January 2026.

Your chat-room Durable Object keeps a this.connectedUsernames = [] array, pushed to in fetch() and read in webSocketMessage(). After a quiet period with no messages, usernames start going missing from broadcasts. What's the most likely cause?
Without scrolling up: what's the one method call that makes a WebSocket connection hibernation-capable, and what does it replace?
Reveal

this.ctx.acceptWebSocket(server), called instead of the standard server.accept(). The standard accept keeps the Durable Object pinned in memory (and billed) for the socket's whole lifetime; acceptWebSocket() lets the runtime evict the object between messages and reconstruct it — including re-running the constructor — when a message actually arrives, which is why per-connection state has to live in serializeAttachment or durable storage rather than plain instance fields.

Anything above unclear — how hibernation interacts with alarms, why attachments are capped at 16 KiB, or how you'd add authentication before the upgrade completes? Ask your AI teacher before moving on.
← Previous: Durable Objects: give a Worker a memory Next: See what your Workers are actually doing →