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.
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.
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 });
}
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();
}
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;
}
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);
}
}
send() here is synchronous, ordinary JavaScript.
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
ws.close(code, reason);
}
}
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.
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.
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.
| State | What's true |
|---|---|
| Active, in-memory | Handling a request or event right now. |
| Idle, hibernateable | Waiting, all hibernation conditions met. After ~10s, hibernates. |
| Hibernated | Removed from memory. WebSocket clients stay connected. In-memory state (anything not in storage) is gone. |
| Active, in-memory | A 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
}
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.
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);
},
};
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.
/?name=lobby. Worker validates the upgrade,
gets the stub, forwards the request.this.ctx.acceptWebSocket(server), returns the 101 response.name=lobby — same
DurableObjectId, same instance. Now two sockets are attached."increment". The runtime re-runs the constructor,
reattaches both sockets, and calls webSocketMessage.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.
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.
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.
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?
setInterval?” “How would I rate-limit messages from one noisy
client without affecting the others?”