Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Durable Objects e WebSockets: multiplayer sem servidor dedicado

Como Durable Objects resolvem o problema de estado compartilhado entre conexões WebSocket em tempo real, com a Hibernation API que elimina o custo de conexões ociosas.

A suposição que quebra a maioria das implementações de WebSocket em serverless é que ter vários Workers aceitando conexões resolve o problema de escala. Não resolve: cria vários mundos isolados onde cada cliente só fala com instâncias do seu próprio Worker, sem visibilidade de quem está conectado às outras. Dois clientes que abrem uma conexão com o mesmo endpoint podem estar em Workers completamente diferentes, sem nenhum canal para trocar mensagens entre si. Esse isolamento é exatamente o que faz Workers escalar — e é exatamente o que inviabiliza qualquer funcionalidade colaborativa ou de presença em tempo real sem uma camada externa de coordenação.

Por que Workers isolados não bastam para multiplayer

Imagine um chat de sala. Dez clientes conectados. A sala existe como conceito na aplicação, mas não existe como objeto em memória em lugar nenhum no Worker. Cada conexão WebSocket é aceita por um Worker que não sabe das outras. Quando o cliente A envia uma mensagem, o Worker que recebe essa mensagem não tem como alcançar os outros nove clientes conectados em outros Workers.

A saída convencional é adicionar uma camada externa: Pub/Sub (Redis, Upstash), banco de dados para persistir mensagens e polling, ou um serviço dedicado de WebSocket como Ably ou Pusher. Todas essas soluções funcionam, mas adicionam um hop de latência, um serviço a gerenciar, e um custo que escala com conexões ativas — não com uso real.

Um Durable Object muda o ponto de coordenação. A sala não fica implícita no conceito da aplicação: ela vira um DO identificado por nome. Todos os clientes que querem entrar em "sala-456" são roteados para o mesmo DO, que mantém a lista de conexões WebSocket em memória e pode entregar mensagens de um para todos sem round-trip externo. A coordenação é local ao DO.

O modelo básico e o custo sem hibernação

A implementação direta de broadcast dentro de um DO é simples. O DO mantém um Set de objetos WebSocket em memória, aceita novas conexões, e itera sobre todas para entregar mensagens:

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 }); } }

Esse código funciona. O problema de custo aparece quando você analisa o modelo de billing: enquanto esse DO tem conexões WebSocket abertas e está processando event listeners, ele está acordado. Mesmo que nenhum cliente esteja enviando mensagens, o DO continua vivo e consumindo GB-segundos. Para uma sala com cinco usuários ociosos por oito horas, o DO fica ativo por oito horas — e você paga por cada segundo de compute nesse período.

A Hibernation API e o que ela muda no modelo de custo

A WebSocket Hibernation API inverte esse custo. Em vez de o DO manter as conexões ativas em memória, você passa o controle para a Cloudflare usando this.ctx.acceptWebSocket(ws) em vez de ws.accept(). A partir daí, a Cloudflare mantém as conexões WebSocket abertas mesmo enquanto o DO hiberna. Quando um cliente envia uma mensagem, a Cloudflare acorda o DO, entrega a mensagem via método webSocketMessage(), e o DO pode hibernar novamente ao terminar de processar.

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); } }

Com hibernação, o custo de compute do DO é proporcional ao tempo processando mensagens, não ao tempo que as conexões ficam abertas. Uma sala com cinco usuários em silêncio por oito horas custa virtualmente zero em compute. O custo real aparece quando usuários estão ativamente trocando mensagens. Para aplicações colaborativas onde períodos de inatividade são comuns — um documento compartilhado que a maioria dos colaboradores abre mas não edita continuamente — a diferença de custo entre o modelo direto e o hibernado pode ser de uma ou duas ordens de magnitude.

this.ctx.getWebSockets() retorna todas as conexões ativas gerenciadas pelo framework de hibernação — equivalente ao Set que você manteria manualmente, mas persistido pela plataforma entre hibernações. Isso significa que você não precisa reconstruir a lista de sessões quando o DO acorda: ela já está disponível.

Estado persistente entre hibernações

Um detalhe que pega quem vem do modelo direto: quando o DO hiberna e acorda, o construtor é chamado novamente, mas o estado em memória (this.sessions, this.roomName, qualquer variável de instância) é perdido. Apenas a storage e as conexões WebSocket gerenciadas pelo framework de hibernação sobrevivem.

Para estado que precisa sobreviver a hibernações — metadados da sala, histórico de mensagens, presença de usuários — a storage é o lugar correto:

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); } }

O padrão que funciona: manter em memória somente o que é derivável da storage e pode ser descartado entre hibernações. Persistir na storage tudo que precisa sobreviver. Inicializar do blockConcurrencyWhile() no construtor quando necessário.

O que você consegue com esse modelo

A combinação de serialização de requisições, WebSocket hibernation e Alarm API resolve um conjunto específico de problemas sem infraestrutura adicional. Edição colaborativa em tempo real onde múltiplos clientes modificam o mesmo documento e precisam ver atualizações com baixa latência. Presença de usuários — saber quem está online numa sala — onde a lista precisa ser consistente mesmo com entradas e saídas simultâneas. Jogos multiplayer com estado de sessão simples onde a frequência de updates justifica a coordenação centralizada. Timers compartilhados entre participantes, como contagens regressivas numa reunião, que precisam ser consistentes para todos.

O teto de throughput ainda existe: um DO que processa cada mensagem em 2ms consegue tratar 500 mensagens por segundo de clientes distintos. Para salas com poucas dezenas de usuários ativos simultaneamente, esse teto nunca é atingido. Para casos com centenas de clientes enviando mensagens de forma contínua ao mesmo DO, o sharding por sala ou por grupo de salas se torna necessário.

O que esse modelo não resolve

Persistência de histórico de mensagens para usuários que chegam offline é um problema separado. A storage do DO guarda o histórico enquanto o DO existe, mas não é um banco de queries. Para buscar mensagens de um período, filtrar por usuário, ou fazer qualquer operação que se beneficie de SQL, você precisa de D1 como storage complementar que o DO popula a cada mensagem.

Escala geográfica também tem limitações. Um DO existe num único PoP. Para aplicações com usuários em regiões muito distantes colaborando na mesma sala, a latência de mensagem inclui o round-trip até o PoP onde o DO está — que pode ser Frankfurt para um usuário em São Paulo. Para a maioria das aplicações colaborativas, essa latência é aceitável. Para jogos que exigem latência abaixo de 50ms para todos os jogadores, a arquitetura precisa ser diferente.

Durable Objects com WebSocket hibernation resolvem o multiplayer sem servidor dedicado para um conjunto real de casos de uso, com um modelo de custo que favorece aplicações onde usuários ficam muito mais tempo lendo do que escrevendo. Fora desse envelope, as limitações ficam visíveis rápido.

Leia também

Durable Objects e WebSockets: multiplayer sem servidor dedicado | Matheus Breguêz