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.

A room object's life with hibernation A room object is active while handling messages, idle when events are drained, hibernated when the runtime evicts it while its sockets stay open, and woken when a message arrives, at which point the constructor re-runs and the message is delivered. A room object's life with hibernation Active handling messages Idle no pending events Hibernated evicted, sockets open Woken constructor re-runs events drained runtime evicts message arrives deliver to handler Duration is billed only while Active and Woken — not while Hibernated
The sockets outlive the object's memory; the object pays only for work.

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.

Share of wall-clock time billed as duration A busy room is billed for all its time under both APIs, but a quiet room drops to about two percent and an idle room answering pings via auto-response drops to zero with the Hibernation API. Share of wall-clock time billed as duration illustrative, per room object standard WebSocket API Hibernation API 0% 25% 50% 75% 100% Busy room (msgs/s) Quiet room (1 msg/min) Idle room (pings only)
Hibernation makes idle rooms nearly free — which is most rooms, most of the time.

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.

A message to a hibernated room Client A's ping is answered by the edge runtime without waking the room; its chat message wakes the object, which runs its constructor, reads the sender from the attachment, stores history, sends to every socket including client B, and hibernates again. A message to a hibernated room Client A Edge runtime Room object Client B ping (auto-response pong) chat message wake: constructor, webSocketMessage read attachment, write history send to all getWebSockets() idle again → hibernate The whole wake-handle-sleep cycle is billed; the silence around it is not
Connections stay put; the object comes and goes.

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.

Back to Serverless & Managed WebSockets.