Cloudflare Durable Objects WebSocket hibernation #
You want each chat room, document or game lobby to be a single coordination point — one place that holds its members’ connections and serializes their messages — without running a fleet of servers or a Redis layer to route between them. Cloudflare Durable Objects give you exactly that: an object with a globally unique name, single-threaded execution, strongly consistent storage, and the ability to accept WebSocket connections. The catch in early versions was billing: an object holding idle WebSockets stayed in memory and accrued duration charges around the clock. The WebSocket Hibernation API fixes that by letting the runtime evict the object from memory while its connections stay open, waking it only when a message arrives.
Root cause #
A Durable Object is an instance of a class, identified by name, that Cloudflare runs in one location at a time. Because every request for a given name reaches the same instance, it is a natural home for per-room state: the member list, the recent history, the current document. With the standard WebSocket API, the object calls ws.accept() and handles events with listeners — which requires the object to stay resident in memory for as long as any socket is open, so an idle room costs as much as a busy one.
The Hibernation API moves socket ownership to the runtime. The object calls this.ctx.acceptWebSocket(ws) instead of ws.accept() and implements handler methods (webSocketMessage, webSocketClose, webSocketError) rather than event listeners. When no events are pending, the runtime can evict the object; the connections remain open at the edge. When a message arrives, the runtime re-instantiates the object, calls its constructor, and delivers the message to the handler. In-memory state is lost across hibernation, so per-connection state is stored in a small serialized attachment on each socket, and room state in the object’s storage.
Resolution #
Route each room to its object by name from a Worker, accept sockets with acceptWebSocket, keep per-connection data in attachments, and use auto-response for heartbeats so pings do not wake the object.
// worker.ts
import { DurableObject } from 'cloudflare:workers';
export interface Env { ROOMS: DurableObjectNamespace<Room> }
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
if (req.headers.get('Upgrade') !== 'websocket') return new Response('expected websocket', { status: 426 });
const user = await verifyTicket(url.searchParams.get('ticket')); // auth before the object
if (!user) return new Response('unauthorized', { status: 401 });
const room = url.searchParams.get('room') ?? 'lobby';
const stub = env.ROOMS.get(env.ROOMS.idFromName(room)); // same name → same object
const headers = new Headers(req.headers);
headers.set('X-User-Id', user.userId);
return stub.fetch(new Request(req, { headers }));
},
};
type Attachment = { userId: string; joinedAt: number };
export class Room extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Runs on every wake-up. Heartbeats answered by the runtime never wake the object.
this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('{"type":"ping"}', '{"type":"pong"}'));
}
async fetch(req: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server); // runtime owns the socket
server.serializeAttachment({ userId: req.headers.get('X-User-Id')!, joinedAt: Date.now() } satisfies Attachment);
// Send recent history from storage so the joiner is current.
const history = (await this.ctx.storage.get<string[]>('history')) ?? [];
server.send(JSON.stringify({ type: 'history', messages: history }));
return new Response(null, { status: 101, webSocket: client });
}
// Called for each incoming message — possibly right after a wake-up.
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer) {
const { userId } = ws.deserializeAttachment() as Attachment; // survives hibernation
const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
if (msg.type !== 'chat') return;
const out = JSON.stringify({ type: 'chat', from: userId, text: String(msg.text).slice(0, 2000), at: Date.now() });
// Persist a bounded history, then fan out to every socket in this room.
const history = ((await this.ctx.storage.get<string[]>('history')) ?? []).slice(-99);
await this.ctx.storage.put('history', [...history, out]);
for (const peer of this.ctx.getWebSockets()) peer.send(out);
}
async webSocketClose(ws: WebSocket, code: number, reason: string) {
ws.close(code, reason); // complete the close handshake
}
}
declare function verifyTicket(t: string | null): Promise<{ userId: string } | null>;
this.ctx.getWebSockets() returns every socket the object has accepted, including across hibernation, so fan-out needs no separate registry. The object’s storage is transactional and local to the object, which makes per-room history, sequence counters and presence straightforward — no cross-node coordination, because there is only one instance per room. That single-instance model is also the limit: one object’s throughput is bounded by one thread, so a room must be sized to what one object can handle, and very large broadcasts should be split across several objects.
Idle clients still need heartbeats to keep intermediaries from closing connections, and the auto-response pair answers them at the edge without waking the object — so a room full of idle members costs nothing in duration. The general interval arithmetic is in tuning WebSocket idle timeouts across proxies.
Edge cases #
State lost on wake. Anything held only in instance fields disappears when the object hibernates. Keep per-connection data in attachments (small — a couple of kilobytes), room data in storage, and treat the constructor as running on every wake-up.
Timers and alarms. setTimeout does not survive hibernation. Use the Durable Object alarm API (this.ctx.storage.setAlarm) for periodic work such as presence sweeps; an alarm wakes the object when it fires.
Hot rooms. A single object processes events one at a time. A room with tens of thousands of members or a very high message rate can saturate it; shard large audiences across several objects (for example, one per thousand members) with a coordinating object that relays messages.
Verification #
Deploy with Wrangler, connect a few clients, and inspect behaviour with wrangler tail:
npx wrangler deploy
npx wscat -c "wss://rt.example.workers.dev/?room=r1&ticket=$TICKET"
> {"type":"ping"} # answered by auto-response: no log line from the object
> {"type":"chat","text":"hi"} # wakes the object: webSocketMessage appears in the tail
npx wrangler tail --format pretty
Confirm that pings produce no object invocations in the tail, that a chat message after a period of idleness still reaches all members (proof that sockets survived hibernation), and that attachments restore the sender’s identity after wake-up. In the dashboard, compare duration charges for an idle room before and after switching to the Hibernation API.
Operational checklist #
FAQ #
What is WebSocket hibernation in Durable Objects? #
It lets the runtime evict a Durable Object from memory while its WebSocket connections remain open, and re-create it when a message arrives. You are billed for duration only while the object is actually running.
Do I lose connections when the object hibernates? #
No. Connections are held by the runtime and survive hibernation. You lose in-memory instance state, which is why per-connection data goes in attachments and room data in storage.
How do I keep connections alive without waking the object? #
Configure setWebSocketAutoResponse with a request/response pair; the runtime answers matching messages (such as an application ping) without invoking your code.
How many connections can one Durable Object handle? #
Many thousands of mostly idle connections are feasible, but all messages for the object are processed on one thread. Size rooms by message rate as well as member count, and shard very large rooms across several objects.
Related #
- AWS API Gateway WebSocket APIs — a serverless model with an external registry.
- Managed vs Self-Hosted WebSocket Services — where edge objects fit.
- Running WebSockets Behind a CDN — the non-edge-compute alternative.
- WebSocket Rooms and Channel Subscriptions — the self-hosted room registry an object replaces.
Back to Serverless & Managed WebSockets.