Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Durable Objects and WebSockets: multiplayer without a dedicated server

How Durable Objects solve the problem of shared state between WebSocket connections in real time, with the Hibernation API that eliminates the cost of idle connections.

Durable Objects and WebSockets: multiplayer without a dedicated server

The assumption that breaks most WebSocket implementations in serverless is that having multiple Workers accepting connections solves the scaling problem. It doesn't solve it: it creates several isolated worlds where each client only talks to instances of their own Worker, with no visibility of who is connected to the others. Two clients opening a connection to the same endpoint can be on completely different Workers, with no channel to exchange messages between them. This isolation is exactly what makes Workers scale — and it's exactly what makes any real-time presence or collaborative functionality unfeasible without an external coordination layer.

Why isolated Workers are not enough for multiplayer

Imagine a room chat. Ten connected clients. The room exists as a concept in the application, but it does not exist as an object in memory anywhere in the Worker. Each WebSocket connection is accepted by a Worker that does not know about the others. When client A sends a message, the Worker that receives that message has no way to reach the other nine clients connected to other Workers.

The conventional solution is to add an external layer: Pub/Sub (Redis, Upstash), database for persisting messages and polling, or a dedicated WebSocket service like Ably or Pusher. All of these solutions work, but they add a hop of latency, a service to manage, and a cost that scales with active connections — not actual usage.

A Durable Object changes the coordination point. The room is not implicit in the application concept: it becomes a DO identified by name. All clients that want to enter "room-456" are routed to the same DO, which keeps the list of WebSocket connections in memory and can deliver one-to-all messages without external round-tripping. Coordination is local to the DO.

The basic model and cost without hibernation

The direct implementation of broadcast within a DO is simple. The DO keeps a Set of WebSocket objects in memory, accepts new connections, and iterates over them all to deliver messages:

export class Room implements DurableObject { private sessions: Set<WebSocket> = new Set(); async fetch(request: Request): Promise<Response> { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); server.accept(); this.sessions.add(server); server.addEventListener('message', (event) => { for (const session of this.sessions) { if (session !== server) { session.send(event.data as string); } } }); server.addEventListener('close', () => { this.sessions.delete(server); }); return new Response(null, { status: 101, webSocket: client }); } }

This code works. The cost problem appears when you analyze the billing model: while this DO has WebSocket connections open and is processing event listeners, it is awake. Even if no client is sending messages, the DO is still alive and consuming GB-seconds. For a room with five users idle for eight hours, the DO is active for eight hours — and you pay for every second of compute during that period.

The Hibernation API and what it changes in the cost model

The WebSocket Hibernation API reverses this cost. Instead of DO keeping active connections in memory, you pass control to Cloudflare using this.ctx.acceptWebSocket(ws) instead of ws.accept(). From there, Cloudflare keeps WebSocket connections open even while the DO sleeps. When a client sends a message, Cloudflare wakes up the DO, delivers the message via method webSocketMessage(), and the DO can sleep again when it finishes processing.

export class Room implements DurableObject { constructor(private ctx: DurableObjectState, private env: Env) {} async fetch(request: Request): Promise<Response> { 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: WebSocket, message: string | ArrayBuffer): Promise<void> { const sockets = this.ctx.getWebSockets(); for (const session of sockets) { if (session !== ws) { session.send(message as string); } } } async webSocketClose(ws: WebSocket, code: number): Promise<void> { ws.close(code); } }

With hibernation, the DO compute cost is proportional to the time processing messages, not the time connections are open. A room with five users in silence for eight hours costs virtually zero in compute. The real cost appears when users are actively messaging each other. For collaborative applications where periods of inactivity are common—a shared document that most collaborators open but do not continually edit—the cost difference between the direct and hibernated model can be one or two orders of magnitude.

this.ctx.getWebSockets() returns all active connections managed by the sleep framework — equivalent to the Set you would maintain manually, but persisted by the platform between sleeps. This means you don't need to rebuild the session list when the DO wakes up: it's already available.

Persistent state between hibernations

A detail that catches those who come from the direct model: when the DO sleeps and wakes up, the constructor is called again, but the state in memory (this.sessions, this.roomName, any instance variable) is lost. Only the storage and WebSocket connections managed by the hibernation framework survive.

For state that needs to survive hibernations — room metadata, message history, user presence — storage is the right place:

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { const data = JSON.parse(message as string); if (data.type === 'join') { const users = await this.ctx.storage.get<string[]>('users') ?? []; users.push(data.userId); await this.ctx.storage.put('users', users); } const sockets = this.ctx.getWebSockets(); for (const session of sockets) { session.send(message as string); } }

The pattern that works: keep in memory only what is derivable from storage and can be discarded between hibernations. Persist in storage everything that needs to survive. Boot from blockConcurrencyWhile() in the constructor when necessary.

What you get with this model

The combination of request serialization, WebSocket hibernation and Alarm API solves a specific set of problems without additional infrastructure. Real-time collaborative editing where multiple clients modify the same document and need to see updates with low latency. Presence of users — knowing who is online in a room — where the list needs to be consistent even with simultaneous entries and exits. Multiplayer games with simple session state where the frequency of updates justifies centralized coordination. Timers shared between participants, like countdowns in a meeting, which need to be consistent for everyone.

The throughput ceiling still exists: a DO that processes each message in 2ms can handle 500 messages per second from different clients. For rooms with a few dozen active users simultaneously, this ceiling is never reached. For cases with hundreds of clients sending messages continuously to the same DO, sharding by room or group of rooms becomes necessary.

What this model does not solve

Message history persistence for users arriving offline is a separate issue. The DO storage stores the history while the DO exists, but it is not a query bank. To search for messages from a period, filter by user, or do any operation that takes advantage of SQL, you need D1 as complementary storage that the DO populates with each message.

Geographic scale also has limitations. A DO exists in a single PoP. For applications with users in very distant regions collaborating in the same room, the message latency includes the round-trip to the PoP where the DO is — which could be Frankfurt for a user in São Paulo. For most collaborative applications, this latency is acceptable. For games that require latency below 50ms for all players, the architecture needs to be different.

Durable Objects with WebSocket hibernation solve multiplayer without a dedicated server for a real set of use cases, with a cost model that favors applications where users spend much more time reading than writing. Outside this envelope, limitations become apparent quickly.

Also read