Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Durable Objects y WebSockets: multijugador sin servidor dedicado

Cómo Durable Objects resuelve el problema del estado compartido entre conexiones WebSocket en tiempo real, con la API de Hibernación que elimina el costo de las conexiones inactivas.

Durable Objects y WebSockets: multijugador sin servidor dedicado

La suposición que rompe la mayoría de las implementaciones de WebSocket en sin servidor es que tener varios trabajadores que acepten conexiones resuelve el problema de escalamiento. No lo resuelve: crea varios mundos aislados donde cada cliente sólo habla con instancias de su propio Worker, sin visibilidad de quién está conectado con los demás. Dos clientes que abren una conexión al mismo punto final pueden estar en trabajadores completamente diferentes, sin ningún canal para intercambiar mensajes entre ellos. Este aislamiento es exactamente lo que hace que Workers escale, y es exactamente lo que hace que cualquier presencia en tiempo real o funcionalidad colaborativa sea inviable sin una capa de coordinación externa.

Por qué los trabajadores aislados no son suficientes para el modo multijugador

Imagínese una sala de chat. Diez clientes conectados. La sala existe como un concepto en la aplicación, pero no existe como un objeto en la memoria en ninguna parte del Trabajador. Cada conexión WebSocket es aceptada por un Trabajador que desconoce las demás. Cuando el cliente A envía un mensaje, el Trabajador que recibe ese mensaje no tiene forma de comunicarse con los otros nueve clientes conectados a otros Trabajadores.

La solución convencional es agregar una capa externa: Pub/Sub (Redis, Upstash), base de datos para mensajes persistentes y sondeos, o un servicio WebSocket dedicado como Ably o Pusher. Todas estas soluciones funcionan, pero agregan un aumento de latencia, un servicio que administrar y un costo que aumenta con las conexiones activas, no con el uso real.

Un objeto duradero cambia el punto de coordinación. La sala no está implícita en el concepto de la aplicación: se convierte en una DO identificada por su nombre. Todos los clientes que desean ingresar a "room-456" se enrutan al mismo DO, que mantiene la lista de conexiones WebSocket en la memoria y puede entregar mensajes uno a todos sin viajes de ida y vuelta externos. La coordinación es local de la DO.

El modelo básico y el coste sin hibernación.

La implementación directa de la difusión dentro de una DO es sencilla. El DO mantiene Set de WebSocket objetos en la memoria, acepta nuevas conexiones y los itera todos para entregar mensajes:

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

Este código funciona. El problema de costos aparece cuando se analiza el modelo de facturación: mientras este DO tiene conexiones WebSocket abiertas y está procesando detectores de eventos, está despierto. Incluso si ningún cliente envía mensajes, el DO sigue vivo y consume GB-segundos. Para una sala con cinco usuarios inactivos durante ocho horas, el DO está activo durante ocho horas y usted paga por cada segundo de cómputo durante ese período.

La API de Hibernación y lo que cambia en el modelo de costes

La API de WebSocket Hibernation invierte este costo. En lugar de mantener conexiones activas en la memoria, pasa el control a Cloudflare usando this.ctx.acceptWebSocket(ws) en lugar de ws.accept(). A partir de ahí, Cloudflare mantiene abiertas las conexiones WebSocket incluso mientras el DO duerme. Cuando un cliente envía un mensaje, Cloudflare activa el DO, entrega el mensaje mediante el método webSocketMessage() y el DO puede volver a dormir cuando termina de procesar.

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

Con la hibernación, el costo de procesamiento de DO es proporcional al tiempo de procesamiento de mensajes, no al tiempo que las conexiones están abiertas. Una sala con cinco usuarios en silencio durante ocho horas cuesta prácticamente cero en computación. El costo real aparece cuando los usuarios se envían mensajes entre sí activamente. Para aplicaciones colaborativas donde los períodos de inactividad son comunes (un documento compartido que la mayoría de los colaboradores abren pero no editan continuamente), la diferencia de costo entre el modelo directo y el hibernado puede ser de uno o dos órdenes de magnitud.

this.ctx.getWebSockets() devuelve todas las conexiones activas administradas por el marco de suspensión, equivalente a Set que mantendría manualmente, pero que la plataforma persiste entre las suspensiones. Esto significa que no necesita reconstruir la lista de sesiones cuando el DO se activa: ya está disponible.

Estado persistente entre hibernaciones

Un detalle que atrapa a los que vienen del modelo directo: cuando el DO duerme y se despierta, se vuelve a llamar al constructor, pero el estado en memoria (this.sessions, this.roomName, cualquier variable de instancia) se pierde. Solo sobreviven el almacenamiento y las conexiones WebSocket administradas por el marco de hibernación.

Para el estado que necesita sobrevivir a las hibernaciones (metadatos de la sala, historial de mensajes, presencia de usuarios), el almacenamiento es el lugar adecuado:

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

El patrón que funciona: mantener en la memoria sólo lo que se puede derivar del almacenamiento y se puede descartar entre hibernaciones. Persiste en almacenamiento todo lo que necesita para sobrevivir. Arranque desde blockConcurrencyWhile() en el constructor cuando sea necesario.

Lo que obtienes con este modelo

La combinación de serialización de solicitudes, hibernación de WebSocket y API de alarma resuelve un conjunto específico de problemas sin infraestructura adicional. Edición colaborativa en tiempo real donde varios clientes modifican el mismo documento y necesitan ver actualizaciones con baja latencia. Presencia de usuarios (saber quién está en línea en una sala) donde la lista debe ser coherente incluso con entradas y salidas simultáneas. Juegos multijugador con estado de sesión simple donde la frecuencia de las actualizaciones justifica la coordinación centralizada. Temporizadores compartidos entre los participantes, como cuentas regresivas en una reunión, que deben ser consistentes para todos.

El límite de rendimiento todavía existe: un DO que procesa cada mensaje en 2 ms puede manejar 500 mensajes por segundo de diferentes clientes. Para salas con unas pocas docenas de usuarios activos simultáneamente, este límite nunca se alcanza. Para casos en los que cientos de clientes envían mensajes continuamente al mismo DO, se hace necesaria la fragmentación por sala o grupo de salas.

Lo que este modelo no resuelve

La persistencia del historial de mensajes para los usuarios que llegan sin conexión es un tema aparte. El almacenamiento DO almacena el historial mientras existe el DO, pero no es un banco de consultas. Para buscar mensajes de un período, filtrar por usuario o realizar cualquier operación que aproveche SQL, necesita D1 como almacenamiento complementario que el DO completa con cada mensaje.

La escala geográfica también tiene limitaciones. Un DO existe en un único PoP. Para aplicaciones con usuarios de regiones muy distantes que colaboran en la misma sala, la latencia del mensaje incluye el viaje de ida y vuelta al PoP donde está el DO, que podría ser Frankfurt para un usuario en São Paulo. Para la mayoría de las aplicaciones colaborativas, esta latencia es aceptable. Para juegos que requieren una latencia inferior a 50 ms para todos los jugadores, la arquitectura debe ser diferente.

Durable Objects con hibernación WebSocket resuelve el modo multijugador sin un servidor dedicado para un conjunto real de casos de uso, con un modelo de costos que favorece aplicaciones donde los usuarios pasan mucho más tiempo leyendo que escribiendo. Fuera de este ámbito, las limitaciones se hacen evidentes rápidamente.

Lea también