Learn Durable Objects › Lesson 4 of 4

Add WebSockets

15 minutes  ·  Hands-on code  ·  Hibernation & broadcast

Listen while you read — AI-generated podcast for this lesson

The counter from Lesson 3 only supported request/response: a client asks for the value, gets an answer, disconnects. This lesson extends it so that every connected client sees updates live, the moment anyone else changes the count — the same pattern behind chat rooms and multiplayer games. The interesting part isn’t the WebSocket API itself, which you already know. It’s what the DO runtime does with it: hibernation while idle, and a broadcast that is safe for the same reason increment() was safe in Lesson 3.

Two WebSocket APIs, one recommended

Cloudflare documents two ways for a DO to serve WebSockets: the Web Standard API (server.accept(), familiar addEventListener pattern) and the Hibernation API (this.ctx.acceptWebSocket(server), handler methods instead of listeners). This lesson uses the Hibernation API exclusively because it is what Cloudflare recommends, and because it exposes the runtime mechanics this course cares about. The Standard API keeps a DO pinned in memory for as long as any socket is open — you pay for idle time. The Hibernation API does not.

The Durable Object class

src/index.ts
export class Counter extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);

    this.ctx.acceptWebSocket(server);

    return new Response(null, { status: 101, webSocket: client });
  }
acceptWebSocket, not accept new WebSocketPair() creates two linked ends of one socket: client goes back to the browser in the 101 Switching Protocols response; server stays here. Calling this.ctx.acceptWebSocket(server) — instead of the standard server.accept() — registers the socket with the Hibernation API. That one call is the entire difference between a DO that must stay resident for the life of the connection and one that can go to sleep between messages.
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    if (typeof message !== "string") return;

    if (message === "increment") await this.increment();
    if (message === "decrement") await this.decrement();
  }
A handler method, not an event listener With the Hibernation API you never call server.addEventListener("message", ...). Instead you define webSocketMessage as a method on the class, and the runtime calls it — on any socket accepted by this instance — when a message arrives. This is what makes hibernation possible: there is no live JS closure holding a reference to the listener while the DO is asleep. The runtime re-attaches the handler by re-running your class's constructor and looking up this method by name.
  async increment(amount = 1): Promise<number> {
    let value = (await this.ctx.storage.get("value")) || 0;
    value += amount;
    await this.ctx.storage.put("value", value);
    this.broadcast(value);
    return value;
  }

  async decrement(amount = 1): Promise<number> {
    let value = (await this.ctx.storage.get("value")) || 0;
    value -= amount;
    await this.ctx.storage.put("value", value);
    this.broadcast(value);
    return value;
  }
Same read-modify-write, one new line This is the exact increment/decrement from Lesson 3, still protected by the input gate, plus a single call to this.broadcast(value). These methods are still callable as plain RPC too — a REST client and a WebSocket client can both trigger an increment on the same instance, and both trigger the same broadcast.
  broadcast(value: number) {
    const payload = JSON.stringify({ value });
    for (const ws of this.ctx.getWebSockets()) {
      ws.send(payload);
    }
  }
this.ctx.getWebSockets() Returns every WebSocket currently accepted by this instance — including ones that were attached before the most recent hibernation cycle. You don't maintain your own array of open sockets; the runtime already tracks the set for you per-instance. Looping and calling send() here is synchronous, ordinary JavaScript.
  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
    ws.close(code, reason);
  }
}
Cleanup handler Called when a client disconnects. this.ctx.getWebSockets() automatically stops returning a socket once it's closed — you don't need to remove it from any list yourself. As of the web_socket_auto_reply_to_close compatibility flag, the runtime auto-replies to Close frames, so calling ws.close() here is safe but no longer strictly required.

Why the single-threaded guarantee matters here

broadcast() loops over every connected client and sends each one the new value. Between the moment the loop starts and the moment it finishes, could another increment() call run, change the stored value again, and have its broadcast interleave with this one — so some clients get stale values out of order?

No. The input gate that protected the read-modify-write in Lesson 3 protects this exactly the same way. increment() is one handler invocation from the runtime's perspective: read storage, write storage, loop over sockets, send. No other handler — not another increment(), not a webSocketMessage from a different client, not an RPC call — runs until this one finishes (or hits its own await and yields cleanly). Every client sees updates in the same order the DO applied them, because there is only ever one call in flight at a time. Broadcasting to a thousand clients still needs no lock, no sequence number, no vector clock.

Hibernation: sleeping without disconnecting

A DO with open WebSocket connections does not need to stay in memory just because the sockets are open. If it satisfies all of the hibernation conditions — no standard WebSocket API in use, no pending timers, no request still being processed, no active outbound connections — the runtime evicts it from memory after about 10 seconds of inactivity. The sockets stay connected to Cloudflare's network the whole time. The next incoming message re-runs the constructor and wakes the instance back up.

StateWhat's true
Active, in-memoryHandling a request or event right now.
Idle, hibernateableWaiting, all hibernation conditions met. After ~10s, hibernates.
HibernatedRemoved from memory. WebSocket clients stay connected. In-memory state (anything not in storage) is gone.
Active, in-memoryA message arrives → constructor re-runs → handler runs.

That "in-memory state is gone" line matters: if your DO kept, say, a per-connection username in a plain JS Map, it disappears every time the instance hibernates. Cloudflare's fix is serializeAttachment() and deserializeAttachment() — a small (16 KB max) piece of state attached directly to the WebSocket object itself, which survives hibernation because it travels with the connection, not with the instance's memory.

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const name = url.searchParams.get("name") ?? "anonymous";

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

    server.serializeAttachment({ name, joinedAt: Date.now() });

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

  async webSocketMessage(ws: WebSocket, message: string) {
    const { name } = ws.deserializeAttachment();
    // ...use `name` even if this instance just woke from hibernation
  }
Attachments survive; local variables don't serializeAttachment is called once, right after accepting the socket. deserializeAttachment() reads it back in any later handler — even one that runs after a full hibernate/wake cycle re-ran the constructor. If you need more than 16 KB or data that must outlive the socket itself, store it in this.ctx.storage instead and keep only a lookup key as the attachment.

The Worker (entry point)

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const name = url.searchParams.get("name");
    const upgradeHeader = request.headers.get("Upgrade");

    if (!name) {
      return new Response("Add ?name=something to the URL", { status: 400 });
    }
    if (upgradeHeader !== "websocket") {
      return new Response("Expected Upgrade: websocket", { status: 426 });
    }

    const id = env.COUNTERS.idFromName(name);
    const stub = env.COUNTERS.get(id);

    return stub.fetch(request);
  },
};
fetch(), not an RPC method, for the upgrade The Worker validates the upgrade request before forwarding it — both Workers and DOs are billed per request, so rejecting malformed requests here avoids billing the DO for them. Note this calls stub.fetch(request), the same generic HTTP entry point every DO has, rather than a custom RPC method. WebSocket upgrades are HTTP requests before they're anything else, and the 101 response with its attached socket has to travel back through that same fetch() call.

Same wrangler config as Lesson 3. No new bindings are required for WebSockets — the durable_objects.bindings and new_sqlite_classes migration from the counter still apply unchanged. Hibernation is a runtime behavior of the class, not something you opt into via config.

What happens end-to-end

  1. Client A opens a WebSocket to /?name=lobby. Worker validates the upgrade, gets the stub, forwards the request.
  2. DO cold-starts (if not already warm), accepts the socket via this.ctx.acceptWebSocket(server), returns the 101 response.
  3. Client B connects the same way, to the same name=lobby — same DurableObjectId, same instance. Now two sockets are attached.
  4. Ten seconds pass with no messages. All hibernation conditions are met. The runtime evicts the instance from memory. Both sockets remain connected from the clients' perspective.
  5. Client A sends "increment". The runtime re-runs the constructor, reattaches both sockets, and calls webSocketMessage.
  6. increment() runs: reads storage, writes storage, calls broadcast(). Both A and B receive the new value — B did nothing to earn that message except staying connected.
  7. The instance goes idle again, and the hibernation clock restarts.

Challenge

Hands-on

Three clients are connected to the same counter room: Alice, Bob, and Carol. The DO has been idle for 12 seconds. Alice's client sends "increment" at the same instant Bob's client sends "decrement" — both messages arrive at the edge within the same millisecond. The counter is currently 10.

Write down: does the DO need to be woken from hibernation first? What value(s) get broadcast, to whom, and in what order? Then check your reasoning below.

Show answer

Yes — 12 idle seconds means the instance already hibernated, so the first message to arrive (Alice's or Bob's, whichever the runtime delivers first) re-runs the constructor and wakes it. Say Alice's "increment" is handled first: webSocketMessage runs, calls increment(), reads 10, writes 11, and broadcast() sends {value: 11} to Alice, Bob, and Carol — all three, not just Alice.

Only once that whole handler invocation finishes does the input gate open for Bob's "decrement". It reads 11 (not 10), writes 10, and broadcasts {value: 10} to all three again. Every client receives both updates, in the same order, with no interleaving — identical guarantee to Lesson 3's concurrent increments, now fanned out to three sockets instead of returned to one caller.


Quiz

Select the best answer.

What does calling this.ctx.acceptWebSocket(server) instead of server.accept() actually change?

Why is it safe for broadcast() to loop over every socket and call send() with no additional synchronization?

A Durable Object hibernates. What happens to a plain JavaScript variable (not stored via storage or an attachment) that was holding per-connection data?

Primary sources for this lesson Use WebSockets — Cloudflare Durable Objects docs — The Hibernation and Web Standard WebSocket APIs, batching, and attachment methods.

Lifecycle of a Durable Object — The full state machine: active, idle-hibernateable, hibernated, inactive, and the timing of each transition.
← Lesson 3: Build a counter DO Back to course home
Questions? Ask your teacher before moving on. Examples: “What happens to a client's socket if the Durable Object moves to a different host?” “Can I use the Hibernation API and still call setInterval?” “How would I rate-limit messages from one noisy client without affecting the others?”