Saltar al contenido principal
Edge Computing

Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge

8 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

Qué Son Realmente los Durable Objects

Un Durable Object es un objeto JavaScript/TypeScript single-threaded que vive en el edge. Puede pensarse como un actor en el modelo de actores, pero el runtime se encarga de toda la parte complica: rutear requests a la instancia correcta, persistir estado, y garantizar acceso single-threaded.

Cada Durable Object tiene:

  • Un ID globalmente único
  • Un contexto de ejecución single-threaded (imposible tener bugs de concurrencia)
  • Una API de storage key-value transaccional (strongly consistent)
  • La capacidad de aceptar conexiones WebSocket
  • Un sistema de alarmas para trabajo programado

La propiedad crítica: solo existe una instancia de un Durable Object dado en todo el mundo en cualquier momento. La plataforma garantiza esto. Si dos requests para el mismo objeto llegan a PoPs distintos de Cloudflare, uno espera mientras el request se rutea a donde sea que ese objeto esté corriendo.

El Game Room: Modelo de Actores en Práctica

A continuación se presenta el esqueleto de una implementación de game server. Cada sala de juego es un Durable Object:

export class GameRoom implements DurableObject {
 private state: DurableObjectState;
 private env: Env;
 private gameState: GameState | null = null;
 private sessions: Map<string, WebSocket> = new Map();

 constructor(state: DurableObjectState, env: Env) {
 this.state = state;
 this.env = env;

 // CRÍTICO: Bloqueá todos los requests hasta que el estado esté cargado
 this.state.blockConcurrencyWhile(async () => {
 const stored = await this.state.storage.get<GameState>("gameState");
 this.gameState = stored ?? createEmptyGame();
 });
 }

 async fetch(request: Request): Promise<Response> {
 const url = new URL(request.url);

 if (url.pathname === "/ws") {
 return this.handleWebSocket(request);
 }

 if (url.pathname === "/state") {
 return Response.json(this.gameState);
 }

 return new Response("Not found", { status: 404 });
 }

 private handleWebSocket(request: Request): Response {
 const pair = new WebSocketPair();
 const [client, server] = Object.values(pair);

 const playerId = new URL(request.url).searchParams.get("playerId");
 if (!playerId) {
 return new Response("Falta playerId", { status: 400 });
 }

 this.state.acceptWebSocket(server, [playerId]);
 this.sessions.set(playerId, server);

 server.send(JSON.stringify({
 type: "sync",
 state: this.gameState,
 yourId: playerId,
 }));

 this.broadcast({
 type: "player_joined",
 playerId,
 playerCount: this.sessions.size,
 }, playerId);

 return new Response(null, { status: 101, webSocket: client });
 }

 async webSocketMessage(ws: WebSocket, message: string) {
 const data = JSON.parse(message);
 const tags = this.state.getTags(ws);
 const playerId = tags[0];

 switch (data.type) {
 case "place_tile":
 this.handlePlaceTile(playerId, data);
 break;
 case "draw_tiles":
 this.handleDrawTiles(playerId);
 break;
 case "end_turn":
 this.handleEndTurn(playerId);
 break;
 }
 }

 async webSocketClose(ws: WebSocket) {
 const tags = this.state.getTags(ws);
 const playerId = tags[0];
 this.sessions.delete(playerId);

 this.broadcast({
 type: "player_left",
 playerId,
 playerCount: this.sessions.size,
 });

 if (this.sessions.size === 0) {
 await this.state.storage.setAlarm(Date.now() + 300_000);
 }
 }

 async alarm() {
 if (this.sessions.size === 0) {
 await this.state.storage.deleteAll();
 }
 }
}

Lo lindo este es el punto la ausencia de complejidad. Sin mutex, sin locks, sin CAS loops, sin control de concurrencia optimista. El runtime garantiza que las llamadas a webSocketMessage están serializadas. Cuando el Jugador A coloca una ficha y el Jugador B roba fichas simultáneamente, se ejecutan una después de la otra, nunca intercaladas. El modelo de actores te da esto gratis.

La Capa de Ruteo: Workers como Matchmakers

Un Cloudflare Worker actúa como punto de entrada, ruteando jugadores al Durable Object correcto:

export default {
 async fetch(request: Request, env: Env): Promise<Response> {
 const url = new URL(request.url);

 if (url.pathname.startsWith("/game/")) {
 const roomId = url.pathname.split("/")[2];

 // El ID es determinístico — mismo roomId siempre rutea al mismo DO
 const id = env.GAME_ROOMS.idFromName(roomId);
 const room = env.GAME_ROOMS.get(id);

 return room.fetch(request);
 }

 if (url.pathname === "/matchmake") {
 return handleMatchmaking(request, env);
 }

 return env.ASSETS.fetch(request);
 }
};

async function handleMatchmaking(request: Request, env: Env): Promise<Response> {
 const { preferredLanguage, skillRating } = await request.json();

 const waitingRoomId = env.GAME_ROOMS.idFromName(
 `waiting:${preferredLanguage}:${Math.floor(skillRating / 100) * 100}`
 );
 const waitingRoom = env.GAME_ROOMS.get(waitingRoomId);

 const response = await waitingRoom.fetch(new Request("https://internal/join", {
 method: "POST",
 body: JSON.stringify({ skillRating }),
 }));

 return response;
}

El insight clave: idFromName() es un hash determinístico. El string "waiting:en:1200" siempre mapea al mismo ID de Durable Object, así que todos los jugadores angloparlantes con ~1200 de rating terminan en la misma sala de matchmaking sin ninguna capa de coordinación.

Gestión de Estado: Storage Transaccional

La API de storage es engañosamente simple pero notablemente poderosa:

private async handlePlaceTile(playerId: string, data: PlaceTileAction) {
 if (this.gameState!.currentTurn !== playerId) {
 this.sendError(playerId, "No es tu turno");
 return;
 }

 const { x, y, letter } = data;
 const validation = validatePlacement(this.gameState!, x, y, letter, playerId);
 if (!validation.valid) {
 this.sendError(playerId, validation.reason);
 return;
 }

 this.gameState!.board[y][x] = { letter, playerId, timestamp: Date.now() };
 this.gameState!.players[playerId].tiles = this.gameState!.players[playerId].tiles
 .filter(t => t !== letter);
 this.gameState!.moveHistory.push({ playerId, x, y, letter, timestamp: Date.now() });

 await this.state.storage.put("gameState", this.gameState);

 this.broadcast({
 type: "tile_placed",
 playerId,
 x, y, letter,
 scores: this.calculateScores(),
 });
}

Para operaciones más complejas, teners transacciones:

await this.state.storage.transaction(async (txn) => {
 const game = await txn.get<GameState>("gameState");
 const stats = await txn.get<PlayerStats>(`stats:${playerId}`);

 game!.currentTurn = nextPlayer(game!);
 stats!.movesPlayed += 1;
 stats!.lastActive = Date.now();

 await txn.put("gameState", game);
 await txn.put(`stats:${playerId}`, stats);
});

Esto es ACID dentro de un solo Durable Object. Los writes o commitean todos o rollbackean todos. Sin updates parciales. Sin reads partidos. En un sistema distribuido tradicional, obtener esta garantía a través de múltiples keys requiere un protocolo de consenso. Este es el punto un one-liner porque el modelo single-threaded hace que el aislamiento serializable sea trivial.

Performance: Lo que Realmente Medí

Un load test con 500 jugadores simulados en 50 salas de juego arroja estos resultados:

| Métrica | Valor | |---------|-------| | Latencia WebSocket message (misma región) | 8-15ms | | Latencia WebSocket message (cross-continente) | 40-90ms | | Latencia de persistencia de estado | 2-5ms | | Cold start (DO nuevo) | 15-25ms | | Procesamiento de mensaje warm | <1ms | | Max WebSockets concurrentes por DO | ~32,000 (testeado) | | Lecturas de storage | <1ms (cacheado en memoria después del primer read) |

La latencia cross-continente merece explicación. Cuando un jugador en Tokio conecta a una sala creada por un jugador en Berlín, el Durable Object está corriendo cerca de Berlín. Los mensajes del jugador de Tokio se rutean por el backbone de Cloudflare hasta Berlín, se procesan, y la respuesta vuelve. Son ~80ms de round trip. No es instantáneo, pero mejor que un server en us-east-1 para ambos jugadores.

Consideraciones Operativas y Limitaciones

Límites de memoria. Cada Durable Object tiene 128 MB. Parece suficiente hasta que se está manteniendo 1000 conexiones WebSocket con estado per-player. Un game state típico serializa a ~2 KB por jugador, permitiendo ~500 jugadores por sala cómodamente. Para más, se necesita shardear salas.

No hay primitivas de comunicación inter-DO. Si la Sala A necesita saber sobre la Sala B (ej. para un bracket de torneo), teners que ir por un Worker fetch. No hay pub/sub built-in entre Durable Objects.

Hibernación y costos. Los Durable Objects con WebSocket Hibernation se facturan solo cuando procesan mensajes activamente, no mientras están idle. Pero cada operación de storage cuesta dinero. Persistir el estado en cada movimiento puede resultar en $12/día en storage writes durante testing. Batchear writes cada 500ms lo baja a $0.80/día:

private pendingWrite = false;

private async scheduleWrite() {
 if (this.pendingWrite) return;
 this.pendingWrite = true;

 setTimeout(async () => {
 await this.state.storage.put("gameState", this.gameState);
 this.pendingWrite = false;
 }, 500);
}

Debugging es áspero. wrangler dev te da Durable Objects locales, pero el comportamiento difiere de producción en formas sutiles. Las operaciones de storage son sincrónicas localmente pero asincrónicas en producción. Un bug común es storage.put() seguido de storage.get() devolviendo datos stale en producción porque no se hizo await del put. Esto funciona perfecto localmente, haciéndolo difícil de detectar.

Patrón de Arquitectura: Durable Objects + Vercel

La arquitectura final queda así:

[Jugadores] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]
 ↓
[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]
 ↓
[Vercel API Routes (cuentas de usuario, leaderboards)]

Vercel maneja el frontend Next.js y la API no-realtime (perfiles, leaderboards, historial). Cloudflare maneja el gameplay real-time. La separación es limpia: todo lo que necesita consistencia fuerte y updates en tiempo real va a Durable Objects; todo lo que es eventually consistent y request/response va a Vercel.

Cuándo NO Usar Durable Objects

No son un backend de propósito general. Anti-patterns específicos a evitar:

  • APIs CRUD: Utilizar una base de datos normal. Los DO agregan latencia para patrones de read/write simples.
  • Computación pesada: 128 MB de memoria, 30 segundos de CPU time limit. No intentes correr ML inference.
  • Agregación global: Si se necesita contar todos los jugadores activos en todas las salas, se necesita un sistema separado. Cada DO está aislado.
  • Datos relacionales: Sin joins, sin indexes, sin queries. La API de storage es un key-value store.

Brillan para: colaboración en tiempo real, juegos multiplayer, chat rooms, coordinación de dispositivos IoT, rate limiting, y cualquier escenario donde un grupo chico de clientes necesita estado compartido strongly consistent con baja latencia.

El Veredicto

Después de tres meses en producción con ~2000 jugadores activos diarios, este tipo de game server cuesta aproximadamente $34/mes en Cloudflare. El equivalente en AWS serían mínimo dos instancias EC2 más un Application Load Balancer más ElastiCache para session state — probablemente $150+/mes con peor latencia fuera de us-east-1.

Más importante, se requieren cero updates de infraestructura. Sin parcheo, sin configuración de scaling, sin health checks, sin auto-scaling groups. La plataforma maneja todo. Cuando una sala está activa, corre. Cuando está idle, hiberna. Cuando los jugadores están en tres continentes, sigue sintiéndose responsivo.

Los Durable Objects no son la respuesta a todo. Pero para el problema específico de "grupos chicos necesitan estado compartido en tiempo real con consistencia fuerte" — que describe una cantidad sorprendente de aplicaciones — representan una de las soluciones más sólidas disponibles.

cloudflaredurable-objectsedge-computingwebsocketsactor-modelworkers

Herramientas mencionadas en este artículo

CloudflareProbá Cloudflare
VercelProbá Vercel
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime