{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare","title":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","description":"Computación stateful en el edge con Cloudflare Durable Objects, coordinación WebSocket, el modelo de actores en el edge, y una arquitectura de server de juego multiplayer corriendo en 300+ ubicaciones.","language":"es","datePublished":"2026-02-16","dateModified":null,"contentFormat":"text/markdown","content":"Edge Computing\n\n# Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge\n\n2026-02-168 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare>)\n\n## Qué Son Realmente los Durable Objects\n\nUn 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.\n\nCada Durable Object tiene:\n\n- Un ID globalmente único\n- Un contexto de ejecución single-threaded (imposible tener bugs de concurrencia)\n- Una API de storage key-value transaccional (strongly consistent)\n- La capacidad de aceptar conexiones WebSocket\n- Un sistema de alarmas para trabajo programado\n\nLa 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](<https://cloudflare.com/>), uno espera mientras el request se rutea a donde sea que ese objeto esté corriendo.\n\n## El Game Room: Modelo de Actores en Práctica\n\nA continuación se presenta el esqueleto de una implementación de game server. Cada sala de juego es un Durable Object:\n\n```typescript\nexport class GameRoom implements DurableObject {\n private state: DurableObjectState;\n private env: Env;\n private gameState: GameState | null = null;\n private sessions: Map<string, WebSocket> = new Map();\n\n constructor(state: DurableObjectState, env: Env) {\n this.state = state;\n this.env = env;\n\n // CRÍTICO: Bloqueá todos los requests hasta que el estado esté cargado\n this.state.blockConcurrencyWhile(async () => {\n const stored = await this.state.storage.get<GameState>(\"gameState\");\n this.gameState = stored ?? createEmptyGame();\n });\n }\n\n async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n\n if (url.pathname === \"/ws\") {\n return this.handleWebSocket(request);\n }\n\n if (url.pathname === \"/state\") {\n return Response.json(this.gameState);\n }\n\n return new Response(\"Not found\", { status: 404 });\n }\n\n private handleWebSocket(request: Request): Response {\n const pair = new WebSocketPair();\n const [client, server] = Object.values(pair);\n\n const playerId = new URL(request.url).searchParams.get(\"playerId\");\n if (!playerId) {\n return new Response(\"Falta playerId\", { status: 400 });\n }\n\n this.state.acceptWebSocket(server, [playerId]);\n this.sessions.set(playerId, server);\n\n server.send(JSON.stringify({\n type: \"sync\",\n state: this.gameState,\n yourId: playerId,\n }));\n\n this.broadcast({\n type: \"player_joined\",\n playerId,\n playerCount: this.sessions.size,\n }, playerId);\n\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, message: string) {\n const data = JSON.parse(message);\n const tags = this.state.getTags(ws);\n const playerId = tags[0];\n\n switch (data.type) {\n case \"place_tile\":\n this.handlePlaceTile(playerId, data);\n break;\n case \"draw_tiles\":\n this.handleDrawTiles(playerId);\n break;\n case \"end_turn\":\n this.handleEndTurn(playerId);\n break;\n }\n }\n\n async webSocketClose(ws: WebSocket) {\n const tags = this.state.getTags(ws);\n const playerId = tags[0];\n this.sessions.delete(playerId);\n\n this.broadcast({\n type: \"player_left\",\n playerId,\n playerCount: this.sessions.size,\n });\n\n if (this.sessions.size === 0) {\n await this.state.storage.setAlarm(Date.now() + 300_000);\n }\n }\n\n async alarm() {\n if (this.sessions.size === 0) {\n await this.state.storage.deleteAll();\n }\n }\n}\n```\n\nLo 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.\n\n## La Capa de Ruteo: Workers como Matchmakers\n\nUn Cloudflare Worker actúa como punto de entrada, ruteando jugadores al Durable Object correcto:\n\n```typescript\nexport default {\n async fetch(request: Request, env: Env): Promise<Response> {\n const url = new URL(request.url);\n\n if (url.pathname.startsWith(\"/game/\")) {\n const roomId = url.pathname.split(\"/\")[2];\n\n // El ID es determinístico, mismo roomId siempre rutea al mismo DO\n const id = env.GAME_ROOMS.idFromName(roomId);\n const room = env.GAME_ROOMS.get(id);\n\n return room.fetch(request);\n }\n\n if (url.pathname === \"/matchmake\") {\n return handleMatchmaking(request, env);\n }\n\n return env.ASSETS.fetch(request);\n }\n};\n\nasync function handleMatchmaking(request: Request, env: Env): Promise<Response> {\n const { preferredLanguage, skillRating } = await request.json();\n\n const waitingRoomId = env.GAME_ROOMS.idFromName(\n `waiting:${preferredLanguage}:${Math.floor(skillRating / 100) * 100}`\n );\n const waitingRoom = env.GAME_ROOMS.get(waitingRoomId);\n\n const response = await waitingRoom.fetch(new Request(\"https://internal/join\", {\n method: \"POST\",\n body: JSON.stringify({ skillRating }),\n }));\n\n return response;\n}\n```\n\nEl 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.\n\n## Gestión de Estado: Storage Transaccional\n\nLa API de storage es engañosamente simple pero notablemente poderosa:\n\n```typescript\nprivate async handlePlaceTile(playerId: string, data: PlaceTileAction) {\n if (this.gameState!.currentTurn !== playerId) {\n this.sendError(playerId, \"No es tu turno\");\n return;\n }\n\n const { x, y, letter } = data;\n const validation = validatePlacement(this.gameState!, x, y, letter, playerId);\n if (!validation.valid) {\n this.sendError(playerId, validation.reason);\n return;\n }\n\n this.gameState!.board[y][x] = { letter, playerId, timestamp: Date.now() };\n this.gameState!.players[playerId].tiles = this.gameState!.players[playerId].tiles\n .filter(t => t !== letter);\n this.gameState!.moveHistory.push({ playerId, x, y, letter, timestamp: Date.now() });\n\n await this.state.storage.put(\"gameState\", this.gameState);\n\n this.broadcast({\n type: \"tile_placed\",\n playerId,\n x, y, letter,\n scores: this.calculateScores(),\n });\n}\n```\n\nPara operaciones más complejas, teners transacciones:\n\n```typescript\nawait this.state.storage.transaction(async (txn) => {\n const game = await txn.get<GameState>(\"gameState\");\n const stats = await txn.get<PlayerStats>(`stats:${playerId}`);\n\n game!.currentTurn = nextPlayer(game!);\n stats!.movesPlayed += 1;\n stats!.lastActive = Date.now();\n\n await txn.put(\"gameState\", game);\n await txn.put(`stats:${playerId}`, stats);\n});\n```\n\nEsto 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.\n\n## Performance: Lo que Realmente Medí\n\nUn load test con 500 jugadores simulados en 50 salas de juego arroja estos resultados:\n\n| Métrica | Valor |\n| --- | --- |\n| Latencia WebSocket message (misma región) | 8-15ms |\n| Latencia WebSocket message (cross-continente) | 40-90ms |\n| Latencia de persistencia de estado | 2-5ms |\n| Cold start (DO nuevo) | 15-25ms |\n| Procesamiento de mensaje warm | \\<1ms |\n| Max WebSockets concurrentes por DO | ~32,000 (testeado) |\n| Lecturas de storage | \\<1ms (cacheado en memoria después del primer read) |\n\nLa 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.\n\n## Consideraciones Operativas y Limitaciones\n\n**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.\n\n**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.\n\n**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:\n\n```typescript\nprivate pendingWrite = false;\n\nprivate async scheduleWrite() {\n if (this.pendingWrite) return;\n this.pendingWrite = true;\n\n setTimeout(async () => {\n await this.state.storage.put(\"gameState\", this.gameState);\n this.pendingWrite = false;\n }, 500);\n}\n```\n\n**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.\n\n## Patrón de Arquitectura: Durable Objects + Vercel\n\nLa arquitectura final queda así:\n\n```\n[Jugadores] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]\n ↓\n[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]\n ↓\n[Vercel API Routes (cuentas de usuario, leaderboards)]\n```\n\n[Vercel](<https://vercel.com/>) 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.\n\n## Cuándo NO Usar Durable Objects\n\nNo son un backend de propósito general. Anti-patterns específicos a evitar:\n\n- **APIs CRUD**: Utilizar una base de datos normal. Los DO agregan latencia para patrones de read/write simples.\n- **Computación pesada**: 128 MB de memoria, 30 segundos de CPU time limit. No intentes correr ML inference.\n- **Agregación global**: Si se necesita contar todos los jugadores activos en todas las salas, se necesita un sistema separado. Cada DO está aislado.\n- **Datos relacionales**: Sin joins, sin indexes, sin queries. La API de storage es un key-value store.\n\nBrillan 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.\n\n## El Veredicto\n\nDespué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.\n\nMá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.\n\nLos 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.\n\n## Preguntas frecuentes\n\n### ¿Cuánto cuesta un Durable Object comparado con un servidor tradicional?\n\nUn Durable Object cuesta $0.15 por millón de requests más $0.50 por GB-mes de storage. Para un game server manejando 1000 jugadores concurrentes a 20 ticks/seg, son aproximadamente $26/mes, comparable a un VPS chico pero distribuido en 300+ ubicaciones edge sin gestión de infraestructura.\n\n### ¿Pueden los Durable Objects manejar game state en tiempo real a 60fps?\n\nNo a 60fps de tick rate server-side, la latencia de red sola lo hace impráctico. Pero 20-30 ticks/seg funciona bien para la mayoría de juegos multiplayer. La ventaja real es latencia sub-50ms a jugadores en todo el mundo porque el DO corre en el PoP de Cloudflare más cercano al primer jugador que conecta.\n\n### ¿Qué pasa cuando un Durable Object crashea en medio de un juego?\n\nLa plataforma lo reinicia automáticamente, y tu estado almacenado (via storage API) sobrevive al crash. El estado en memoria se pierde, así que teners que diseñar para recovery. Hacer checkpoint del game state crítico cada 500ms a storage asegura que en el restart los clientes se reconecten y reciban el último checkpoint.\n\n## Tips\n\n1. **Tip:** Utilizar siempre ` blockConcurrencyWhile() ` en el constructor de tu Durable Object para inicialización. Sin eso, múltiples requests pueden pegarle a tu objeto antes de que el estado esté cargado, causando race conditions imposibles de reproducir localmente.\n2. **Tip:** Mantener el storage de tu Durable Object abajo de 128 KB por key. La API de storage está optimizada para muchas keys chicas, no pocas grandes. Si se necesita guardar un game state grande, distribuirlo en keys tipo ` state:chunk:0 `, ` state:chunk:1 `, etc.\n3. **Tip:** Utilizar la API de ` alarm() ` para garbage collection de Durable Objects idle en vez de confiar en que la plataforma los evicte. Configurar una alarma en el timestamp de última actividad, y en el handler del alarm, verificar si el objeto debería autodestruirse. Esto evita que objetos fantasma acumulen costos de storage.\n\n- cloudflare\n- durable-objects\n- edge-computing\n- websockets\n- actor-model\n- workers\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#title"},{"level":2,"text":"Qué Son Realmente los Durable Objects","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#qu-son-realmente-los-durable-objects"},{"level":2,"text":"El Game Room: Modelo de Actores en Práctica","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#el-game-room-modelo-de-actores-en-prctica"},{"level":2,"text":"La Capa de Ruteo: Workers como Matchmakers","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#la-capa-de-ruteo-workers-como-matchmakers"},{"level":2,"text":"Gestión de Estado: Storage Transaccional","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#gestin-de-estado-storage-transaccional"},{"level":2,"text":"Performance: Lo que Realmente Medí","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#performance-lo-que-realmente-med"},{"level":2,"text":"Consideraciones Operativas y Limitaciones","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#consideraciones-operativas-y-limitaciones"},{"level":2,"text":"Patrón de Arquitectura: Durable Objects + Vercel","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#patrn-de-arquitectura-durable-objects-vercel"},{"level":2,"text":"Cuándo NO Usar Durable Objects","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#cundo-no-usar-durable-objects"},{"level":2,"text":"El Veredicto","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#el-veredicto"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#questions"},{"level":3,"text":"¿Cuánto cuesta un Durable Object comparado con un servidor tradicional?","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-1"},{"level":3,"text":"¿Pueden los Durable Objects manejar game state en tiempo real a 60fps?","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-2"},{"level":3,"text":"¿Qué pasa cuando un Durable Object crashea en medio de un juego?","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#article-tips"}],"sections":[{"heading":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","level":1,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare","content":"Edge Computing"},{"heading":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","level":1,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#title","content":"2026-02-168 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare>)"},{"heading":"Qué Son Realmente los Durable Objects","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#qu-son-realmente-los-durable-objects","content":"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.\n\nCada Durable Object tiene:\n\n- Un ID globalmente único\n- Un contexto de ejecución single-threaded (imposible tener bugs de concurrencia)\n- Una API de storage key-value transaccional (strongly consistent)\n- La capacidad de aceptar conexiones WebSocket\n- Un sistema de alarmas para trabajo programado\n\nLa 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](<https://cloudflare.com/>), uno espera mientras el request se rutea a donde sea que ese objeto esté corriendo."},{"heading":"El Game Room: Modelo de Actores en Práctica","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#el-game-room-modelo-de-actores-en-prctica","content":"A continuación se presenta el esqueleto de una implementación de game server. Cada sala de juego es un Durable Object:\n\n```typescript\nexport class GameRoom implements DurableObject {\n private state: DurableObjectState;\n private env: Env;\n private gameState: GameState | null = null;\n private sessions: Map<string, WebSocket> = new Map();\n\n constructor(state: DurableObjectState, env: Env) {\n this.state = state;\n this.env = env;\n\n // CRÍTICO: Bloqueá todos los requests hasta que el estado esté cargado\n this.state.blockConcurrencyWhile(async () => {\n const stored = await this.state.storage.get<GameState>(\"gameState\");\n this.gameState = stored ?? createEmptyGame();\n });\n }\n\n async fetch(request: Request): Promise<Response> {\n const url = new URL(request.url);\n\n if (url.pathname === \"/ws\") {\n return this.handleWebSocket(request);\n }\n\n if (url.pathname === \"/state\") {\n return Response.json(this.gameState);\n }\n\n return new Response(\"Not found\", { status: 404 });\n }\n\n private handleWebSocket(request: Request): Response {\n const pair = new WebSocketPair();\n const [client, server] = Object.values(pair);\n\n const playerId = new URL(request.url).searchParams.get(\"playerId\");\n if (!playerId) {\n return new Response(\"Falta playerId\", { status: 400 });\n }\n\n this.state.acceptWebSocket(server, [playerId]);\n this.sessions.set(playerId, server);\n\n server.send(JSON.stringify({\n type: \"sync\",\n state: this.gameState,\n yourId: playerId,\n }));\n\n this.broadcast({\n type: \"player_joined\",\n playerId,\n playerCount: this.sessions.size,\n }, playerId);\n\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, message: string) {\n const data = JSON.parse(message);\n const tags = this.state.getTags(ws);\n const playerId = tags[0];\n\n switch (data.type) {\n case \"place_tile\":\n this.handlePlaceTile(playerId, data);\n break;\n case \"draw_tiles\":\n this.handleDrawTiles(playerId);\n break;\n case \"end_turn\":\n this.handleEndTurn(playerId);\n break;\n }\n }\n\n async webSocketClose(ws: WebSocket) {\n const tags = this.state.getTags(ws);\n const playerId = tags[0];\n this.sessions.delete(playerId);\n\n this.broadcast({\n type: \"player_left\",\n playerId,\n playerCount: this.sessions.size,\n });\n\n if (this.sessions.size === 0) {\n await this.state.storage.setAlarm(Date.now() + 300_000);\n }\n }\n\n async alarm() {\n if (this.sessions.size === 0) {\n await this.state.storage.deleteAll();\n }\n }\n}\n```\n\nLo 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."},{"heading":"La Capa de Ruteo: Workers como Matchmakers","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#la-capa-de-ruteo-workers-como-matchmakers","content":"Un Cloudflare Worker actúa como punto de entrada, ruteando jugadores al Durable Object correcto:\n\n```typescript\nexport default {\n async fetch(request: Request, env: Env): Promise<Response> {\n const url = new URL(request.url);\n\n if (url.pathname.startsWith(\"/game/\")) {\n const roomId = url.pathname.split(\"/\")[2];\n\n // El ID es determinístico, mismo roomId siempre rutea al mismo DO\n const id = env.GAME_ROOMS.idFromName(roomId);\n const room = env.GAME_ROOMS.get(id);\n\n return room.fetch(request);\n }\n\n if (url.pathname === \"/matchmake\") {\n return handleMatchmaking(request, env);\n }\n\n return env.ASSETS.fetch(request);\n }\n};\n\nasync function handleMatchmaking(request: Request, env: Env): Promise<Response> {\n const { preferredLanguage, skillRating } = await request.json();\n\n const waitingRoomId = env.GAME_ROOMS.idFromName(\n `waiting:${preferredLanguage}:${Math.floor(skillRating / 100) * 100}`\n );\n const waitingRoom = env.GAME_ROOMS.get(waitingRoomId);\n\n const response = await waitingRoom.fetch(new Request(\"https://internal/join\", {\n method: \"POST\",\n body: JSON.stringify({ skillRating }),\n }));\n\n return response;\n}\n```\n\nEl 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."},{"heading":"Gestión de Estado: Storage Transaccional","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#gestin-de-estado-storage-transaccional","content":"La API de storage es engañosamente simple pero notablemente poderosa:\n\n```typescript\nprivate async handlePlaceTile(playerId: string, data: PlaceTileAction) {\n if (this.gameState!.currentTurn !== playerId) {\n this.sendError(playerId, \"No es tu turno\");\n return;\n }\n\n const { x, y, letter } = data;\n const validation = validatePlacement(this.gameState!, x, y, letter, playerId);\n if (!validation.valid) {\n this.sendError(playerId, validation.reason);\n return;\n }\n\n this.gameState!.board[y][x] = { letter, playerId, timestamp: Date.now() };\n this.gameState!.players[playerId].tiles = this.gameState!.players[playerId].tiles\n .filter(t => t !== letter);\n this.gameState!.moveHistory.push({ playerId, x, y, letter, timestamp: Date.now() });\n\n await this.state.storage.put(\"gameState\", this.gameState);\n\n this.broadcast({\n type: \"tile_placed\",\n playerId,\n x, y, letter,\n scores: this.calculateScores(),\n });\n}\n```\n\nPara operaciones más complejas, teners transacciones:\n\n```typescript\nawait this.state.storage.transaction(async (txn) => {\n const game = await txn.get<GameState>(\"gameState\");\n const stats = await txn.get<PlayerStats>(`stats:${playerId}`);\n\n game!.currentTurn = nextPlayer(game!);\n stats!.movesPlayed += 1;\n stats!.lastActive = Date.now();\n\n await txn.put(\"gameState\", game);\n await txn.put(`stats:${playerId}`, stats);\n});\n```\n\nEsto 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."},{"heading":"Performance: Lo que Realmente Medí","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#performance-lo-que-realmente-med","content":"Un load test con 500 jugadores simulados en 50 salas de juego arroja estos resultados:\n\n| Métrica | Valor |\n| --- | --- |\n| Latencia WebSocket message (misma región) | 8-15ms |\n| Latencia WebSocket message (cross-continente) | 40-90ms |\n| Latencia de persistencia de estado | 2-5ms |\n| Cold start (DO nuevo) | 15-25ms |\n| Procesamiento de mensaje warm | \\<1ms |\n| Max WebSockets concurrentes por DO | ~32,000 (testeado) |\n| Lecturas de storage | \\<1ms (cacheado en memoria después del primer read) |\n\nLa 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."},{"heading":"Consideraciones Operativas y Limitaciones","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#consideraciones-operativas-y-limitaciones","content":"**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.\n\n**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.\n\n**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:\n\n```typescript\nprivate pendingWrite = false;\n\nprivate async scheduleWrite() {\n if (this.pendingWrite) return;\n this.pendingWrite = true;\n\n setTimeout(async () => {\n await this.state.storage.put(\"gameState\", this.gameState);\n this.pendingWrite = false;\n }, 500);\n}\n```\n\n**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."},{"heading":"Patrón de Arquitectura: Durable Objects + Vercel","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#patrn-de-arquitectura-durable-objects-vercel","content":"La arquitectura final queda así:\n\n```\n[Jugadores] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]\n ↓\n[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]\n ↓\n[Vercel API Routes (cuentas de usuario, leaderboards)]\n```\n\n[Vercel](<https://vercel.com/>) 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."},{"heading":"Cuándo NO Usar Durable Objects","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#cundo-no-usar-durable-objects","content":"No son un backend de propósito general. Anti-patterns específicos a evitar:\n\n- **APIs CRUD**: Utilizar una base de datos normal. Los DO agregan latencia para patrones de read/write simples.\n- **Computación pesada**: 128 MB de memoria, 30 segundos de CPU time limit. No intentes correr ML inference.\n- **Agregación global**: Si se necesita contar todos los jugadores activos en todas las salas, se necesita un sistema separado. Cada DO está aislado.\n- **Datos relacionales**: Sin joins, sin indexes, sin queries. La API de storage es un key-value store.\n\nBrillan 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."},{"heading":"El Veredicto","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#el-veredicto","content":"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.\n\nMá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.\n\nLos 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#questions","content":""},{"heading":"¿Cuánto cuesta un Durable Object comparado con un servidor tradicional?","level":3,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-1","content":"Un Durable Object cuesta $0.15 por millón de requests más $0.50 por GB-mes de storage. Para un game server manejando 1000 jugadores concurrentes a 20 ticks/seg, son aproximadamente $26/mes, comparable a un VPS chico pero distribuido en 300+ ubicaciones edge sin gestión de infraestructura."},{"heading":"¿Pueden los Durable Objects manejar game state en tiempo real a 60fps?","level":3,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-2","content":"No a 60fps de tick rate server-side, la latencia de red sola lo hace impráctico. Pero 20-30 ticks/seg funciona bien para la mayoría de juegos multiplayer. La ventaja real es latencia sub-50ms a jugadores en todo el mundo porque el DO corre en el PoP de Cloudflare más cercano al primer jugador que conecta."},{"heading":"¿Qué pasa cuando un Durable Object crashea en medio de un juego?","level":3,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#question-3","content":"La plataforma lo reinicia automáticamente, y tu estado almacenado (via storage API) sobrevive al crash. El estado en memoria se pierde, así que teners que diseñar para recovery. Hacer checkpoint del game state crítico cada 500ms a storage asegura que en el restart los clientes se reconecten y reciban el último checkpoint."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#article-tips","content":"1. **Tip:** Utilizar siempre ` blockConcurrencyWhile() ` en el constructor de tu Durable Object para inicialización. Sin eso, múltiples requests pueden pegarle a tu objeto antes de que el estado esté cargado, causando race conditions imposibles de reproducir localmente.\n2. **Tip:** Mantener el storage de tu Durable Object abajo de 128 KB por key. La API de storage está optimizada para muchas keys chicas, no pocas grandes. Si se necesita guardar un game state grande, distribuirlo en keys tipo ` state:chunk:0 `, ` state:chunk:1 `, etc.\n3. **Tip:** Utilizar la API de ` alarm() ` para garbage collection de Durable Objects idle en vez de confiar en que la plataforma los evicte. Configurar una alarma en el timestamp de última actividad, y en el handler del alarm, verificar si el objeto debería autodestruirse. Esto evita que objetos fantasma acumulen costos de storage.\n\n- cloudflare\n- durable-objects\n- edge-computing\n- websockets\n- actor-model\n- workers\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare"},{"text":"Cloudflare","url":"https://cloudflare.com/"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare"},"headline":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","description":"Computación stateful en el edge con Cloudflare Durable Objects, coordinación WebSocket, el modelo de actores en el edge, y una arquitectura de server de juego multiplayer corriendo en 300+ ubicaciones.","datePublished":"2026-02-16","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Edge Computing","keywords":"cloudflare, durable-objects, edge-computing, websockets, actor-model, workers","wordCount":1527,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Cloudflare Durable Objects: Construcción de un Game Server Stateful en el Edge","item":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Cuánto cuesta un Durable Object comparado con un servidor tradicional?","acceptedAnswer":{"@type":"Answer","text":"Un Durable Object cuesta $0.15 por millón de requests más $0.50 por GB-mes de storage. Para un game server manejando 1000 jugadores concurrentes a 20 ticks/seg, son aproximadamente $26/mes, comparable a un VPS chico pero distribuido en 300+ ubicaciones edge sin gestión de infraestructura."}},{"@type":"Question","name":"¿Pueden los Durable Objects manejar game state en tiempo real a 60fps?","acceptedAnswer":{"@type":"Answer","text":"No a 60fps de tick rate server-side, la latencia de red sola lo hace impráctico. Pero 20-30 ticks/seg funciona bien para la mayoría de juegos multiplayer. La ventaja real es latencia sub-50ms a jugadores en todo el mundo porque el DO corre en el PoP de Cloudflare más cercano al primer jugador que conecta."}},{"@type":"Question","name":"¿Qué pasa cuando un Durable Object crashea en medio de un juego?","acceptedAnswer":{"@type":"Answer","text":"La plataforma lo reinicia automáticamente, y tu estado almacenado (via storage API) sobrevive al crash. El estado en memoria se pierde, así que teners que diseñar para recovery. Hacer checkpoint del game state crítico cada 500ms a storage asegura que en el restart los clientes se reconecten y reciban el último checkpoint."}}]}]}],"contentHash":"20c372cdfec433655d4baac43f8f4033ec94b9b2bc37d527b9d5f30b14d65618","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67951,"contentBytes":14584}}
