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.
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.
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.
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);
}
}
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.
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.
Durable Objects billing (paid plan) has two compute-related components plus storage, per the pricing page:
| Meter | Included | Additional |
|---|---|---|
| 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).
getWebSockets() as the source of truth for who's currently attached.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.
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.
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?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.