{"schemaVersion":1,"id":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare","title":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","description":"Stateful edge computing with Cloudflare Durable Objects, WebSocket coordination, the actor model at the edge, and a multiplayer game server architecture running in 300+ locations.","language":"en","datePublished":"2026-02-16","dateModified":null,"contentFormat":"text/markdown","content":"Edge Computing\n\n# Cloudflare Durable Objects: Building a Stateful Game Server at the Edge\n\n2026-02-1610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare>)\n\n## What Durable Objects Actually Are\n\nA Durable Object is a single-threaded JavaScript/TypeScript object that lives at the edge. Think of it as one actor in the actor model, but the runtime handles all the hard parts: routing requests to the right instance, persisting state, and ensuring single-threaded access.\n\nEach Durable Object has:\n\n- A globally unique ID\n- A single-threaded execution context (no concurrency bugs possible)\n- A transactional key-value storage API (strongly consistent)\n- The ability to accept WebSocket connections\n- An alarm system for scheduled work\n\nThe critical property: **only one instance of a given Durable Object exists anywhere in the world at any time**. The platform guarantees this. If two requests for the same object arrive at different [Cloudflare](<https://cloudflare.com/>) PoPs, one waits while the request is routed to wherever that object is currently running.\n\n```typescript\n// wrangler.toml\n[durable_objects]\nbindings = [\n { name = \"GAME_ROOMS\", class_name = \"GameRoom\" }\n]\n\n[[migrations]]\ntag = \"v1\"\nnew_classes = [\"GameRoom\"]\n```\n\n## The Game Room: Actor Model in Practice\n\nHere is the skeleton of a game server implementation. Each game room is one 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 // CRITICAL: Block all requests until state is loaded\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(\"Missing playerId\", { status: 400 });\n }\n\n this.state.acceptWebSocket(server, [playerId]);\n this.sessions.set(playerId, server);\n\n // Send current state to the new player\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 // Set an alarm to clean up if everyone leaves\n if (this.sessions.size === 0) {\n await this.state.storage.setAlarm(Date.now() + 300_000); // 5 minutes\n }\n }\n\n async alarm() {\n if (this.sessions.size === 0) {\n // No one reconnected, clean up\n await this.state.storage.deleteAll();\n }\n }\n\n // ...\n}\n```\n\nThe beauty here is the absence of complexity. No mutex, no locks, no CAS loops, no optimistic concurrency control. The runtime guarantees that ` webSocketMessage ` calls are serialized. When Player A places a tile and Player B draws tiles simultaneously, they execute one after the other, never interleaved. The actor model gives you this for free.\n\n## The Routing Layer: Workers as Matchmakers\n\nA [Cloudflare Worker](<https://workers.cloudflare.com/>) acts as the entry point, routing players to the right Durable Object:\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 // The ID is deterministic, same roomId always routes to the same DO\n const id = env.GAME_ROOMS.idFromName(roomId);\n const room = env.GAME_ROOMS.get(id);\n\n // This request is forwarded to wherever the DO is running\n return room.fetch(request);\n }\n\n if (url.pathname === \"/matchmake\") {\n return handleMatchmaking(request, env);\n }\n\n // Serve static assets from the edge (or proxy to Vercel)\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 // Check waiting rooms by language\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\nThe key insight: ` idFromName() ` is a deterministic hash. The string ` \"waiting:en:1200\" ` always maps to the same Durable Object ID, so all English-speaking players with ~1200 rating end up in the same matchmaking room without any coordination layer.\n\n## State Management: Transactional Storage\n\nThe storage API is deceptively simple but remarkably powerful:\n\n```typescript\nprivate async handlePlaceTile(playerId: string, data: PlaceTileAction) {\n if (this.gameState!.currentTurn !== playerId) {\n this.sendError(playerId, \"Not your turn\");\n return;\n }\n\n const { x, y, letter } = data;\n\n // Validate the move\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 // Apply the move\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 // Persist atomically, if this fails, in-memory state is stale\n // but the next request will reload from storage via blockConcurrencyWhile\n await this.state.storage.put(\"gameState\", this.gameState);\n\n // Broadcast to all players\n this.broadcast({\n type: \"tile_placed\",\n playerId,\n x, y, letter,\n scores: this.calculateScores(),\n });\n}\n```\n\nFor more complex operations, you get transactions:\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\nThis is ACID within a single Durable Object. The writes either all commit or all roll back. No partial updates. No torn reads. In a traditional distributed system, getting this guarantee across multiple keys requires a consensus protocol. Here, it's a one-liner because the single-threaded model makes serializable isolation trivial.\n\n## Performance: What I Actually Measured\n\nA load test with 500 simulated players across 50 game rooms yields these results:\n\n| Metric | Value |\n| --- | --- |\n| WebSocket message latency (same region) | 8-15ms |\n| WebSocket message latency (cross-continent) | 40-90ms |\n| State persistence latency | 2-5ms |\n| Cold start (new DO) | 15-25ms |\n| Warm message processing | \\<1ms |\n| Max concurrent WebSockets per DO | ~32,000 (tested) |\n| Storage reads | \\<1ms (cached in-memory after first read) |\n\nThe cross-continent latency deserves explanation. When a player in Tokyo connects to a room that was created by a player in Berlin, the Durable Object is running near Berlin. The Tokyo player's messages route through Cloudflare's backbone to Berlin, get processed, and the response routes back. That's ~80ms round trip. Not instant, but better than a server in us-east-1 for both players.\n\nFor a word game, 80ms is fine, it's a turn-based game, not a twitch shooter. For real-time games like .io games, you'd want to use the location hints API to spawn the DO near the majority of players:\n\n```typescript\nconst id = env.GAME_ROOMS.newUniqueId({\n jurisdiction: \"eu\" // Optional: restrict to EU for GDPR\n});\n\n// Or use locationHint for latency optimization\nconst id = env.GAME_ROOMS.idFromName(roomId);\nconst room = env.GAME_ROOMS.get(id, { locationHint: \"enam\" }); // Eastern North America\n```\n\n## Operational Considerations and Limitations\n\n**Memory limits.** Each Durable Object gets 128 MB of memory. That sounds like a lot until you're holding 1000 WebSocket connections with per-player state. A typical game state serializes to ~2 KB per player, allowing ~500 players per room comfortably. For more, you need to shard rooms.\n\n**No inter-DO communication primitives.** If Room A needs to know about Room B (e.g., for a tournament bracket), you have to go through a Worker fetch. There's no built-in pub/sub between Durable Objects. A simple relay pattern addresses this:\n\n```typescript\n// Tournament coordinator DO\nasync notifyRooms(roomIds: string[], event: TournamentEvent) {\n const promises = roomIds.map(async (roomId) => {\n const id = this.env.GAME_ROOMS.idFromName(roomId);\n const room = this.env.GAME_ROOMS.get(id);\n return room.fetch(new Request(\"https://internal/tournament-event\", {\n method: \"POST\",\n body: JSON.stringify(event),\n }));\n });\n\n await Promise.allSettled(promises);\n}\n```\n\n**Hibernation and cost.** Durable Objects with WebSocket Hibernation are billed only when actively processing messages, not while idle. But every storage operation costs money. Persisting state on every single move can result in $12/day in storage writes during testing. Batching writes to every 500ms cuts that to $0.80/day:\n\n```typescript\nprivate pendingWrite = false;\n\nprivate async scheduleWrite() {\n if (this.pendingWrite) return;\n this.pendingWrite = true;\n\n // Coalesce writes, multiple moves within 500ms become one storage write\n setTimeout(async () => {\n await this.state.storage.put(\"gameState\", this.gameState);\n this.pendingWrite = false;\n }, 500);\n}\n```\n\n**Debugging is rough.** ` wrangler dev ` gives you local Durable Objects, but the behavior differs from production in subtle ways. Storage operations are synchronous locally but asynchronous in production. A common bug is ` storage.put() ` followed by ` storage.get() ` returning stale data in production because the put was not awaited. This works perfectly locally, making it difficult to catch.\n\n## Architecture Pattern: Durable Objects + Vercel\n\nThe final architecture looks like this:\n\n```\n[Players] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]\n ↓\n[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]\n ↓\n[Vercel API Routes (user accounts, leaderboards)]\n```\n\n[Vercel](<https://vercel.com/>) handles the Next.js frontend and the non-realtime API (user profiles, leaderboards, game history). Cloudflare handles the real-time game play. The separation is clean: anything that needs strong consistency and real-time updates goes to Durable Objects; anything that's eventually consistent and request/response goes to Vercel.\n\nThe frontend connects to both:\n\n```typescript\n// Vercel API for account data\nconst profile = await fetch(\"/api/profile\").then(r => r.json());\n\n// Cloudflare Worker for game WebSocket\nconst ws = new WebSocket(\n `wss://game.example.com/game/${roomId}/ws?playerId=${profile.id}`\n);\n```\n\n## When Not to Use Durable Objects\n\nThey're not a general-purpose backend. Specific anti-patterns to avoid:\n\n- **CRUD APIs**: Use a normal database. Durable Objects add latency for simple read/write patterns.\n- **Heavy computation**: 128 MB memory, 30-second CPU time limit. Don't try to run ML inference.\n- **Global aggregation**: If you need to count all active players across all rooms, you need a separate system. Each DO is isolated.\n- **Relational data**: No joins, no indexes, no queries. The storage API is a key-value store.\n\nThey shine for: real-time collaboration, multiplayer games, chat rooms, IoT device coordination, rate limiting, and any scenario where a small group of clients need strongly consistent shared state with low latency.\n\n## The Verdict\n\nAfter three months in production with ~2000 daily active players, this type of game server costs roughly $34/month on Cloudflare (Workers + Durable Objects + storage). The equivalent on AWS would be at least two EC2 instances plus an Application Load Balancer plus ElastiCache for session state, probably $150+/month for worse latency outside us-east-1.\n\nMore importantly, zero infrastructure updates are needed. No patching, no scaling configuration, no health checks, no auto-scaling groups. The platform handles all of it. When a game room is active, it runs. When it's idle, it hibernates. When players are on three continents, it still feels responsive.\n\nDurable Objects aren't the answer to everything. But for the specific problem of \"small groups need real-time shared state with strong consistency\", which describes a surprising number of applications, they represent one of the strongest solutions available.\n\n## Frequently asked questions\n\n### How much does a Durable Object cost compared to a traditional server?\n\nA Durable Object costs $0.15 per million requests plus $0.50 per GB-month of storage. For a game server handling 1000 concurrent players at 20 ticks/sec, that's roughly $26/month, comparable to a small VPS but distributed across 300+ edge locations with zero infrastructure management.\n\n### Can Durable Objects handle real-time game state at 60fps?\n\nNot at 60fps server-side tick rate, network latency alone makes that impractical. But 20-30 ticks/sec works well for most multiplayer games. The real advantage is sub-50ms latency to players worldwide since the Durable Object runs in the nearest Cloudflare PoP to the first player who connects.\n\n### What happens when a Durable Object crashes mid-game?\n\nThe platform automatically restarts it, and your stored state (via the storage API) survives the crash. In-memory state is lost, so you need to design for recovery. Checkpointing critical game state every 500ms to storage ensures that on restart, clients reconnect and receive the last checkpoint.\n\n## Tips\n\n1. **Tip:** Always use ` blockConcurrencyWhile() ` in your Durable Object constructor for initialization. Without it, multiple requests can hit your object before the state is loaded, causing race conditions that are impossible to reproduce locally.\n2. **Tip:** Keep your Durable Object storage under 128 KB per key. The storage API is optimized for many small keys, not few large ones. If you need to store a big game state, shard it across keys like ` state:chunk:0 `, ` state:chunk:1 `, etc.\n3. **Tip:** Use the ` alarm() ` API for garbage collection of idle Durable Objects instead of relying on the platform to evict them. Set an alarm on the last activity timestamp, and in the alarm handler, check if the object should self-destruct. This prevents ghost objects from accumulating storage costs.\n\n- cloudflare\n- durable-objects\n- edge-computing\n- websockets\n- actor-model\n- workers\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#title"},{"level":2,"text":"What Durable Objects Actually Are","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#what-durable-objects-actually-are"},{"level":2,"text":"The Game Room: Actor Model in Practice","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-game-room-actor-model-in-practice"},{"level":2,"text":"The Routing Layer: Workers as Matchmakers","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-routing-layer-workers-as-matchmakers"},{"level":2,"text":"State Management: Transactional Storage","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#state-management-transactional-storage"},{"level":2,"text":"Performance: What I Actually Measured","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#performance-what-i-actually-measured"},{"level":2,"text":"Operational Considerations and Limitations","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#operational-considerations-and-limitations"},{"level":2,"text":"Architecture Pattern: Durable Objects + Vercel","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#architecture-pattern-durable-objects-vercel"},{"level":2,"text":"When Not to Use Durable Objects","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#when-not-to-use-durable-objects"},{"level":2,"text":"The Verdict","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-verdict"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#questions"},{"level":3,"text":"How much does a Durable Object cost compared to a traditional server?","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-1"},{"level":3,"text":"Can Durable Objects handle real-time game state at 60fps?","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-2"},{"level":3,"text":"What happens when a Durable Object crashes mid-game?","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#article-tips"}],"sections":[{"heading":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","level":1,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare","content":"Edge Computing"},{"heading":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","level":1,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#title","content":"2026-02-1610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare>)"},{"heading":"What Durable Objects Actually Are","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#what-durable-objects-actually-are","content":"A Durable Object is a single-threaded JavaScript/TypeScript object that lives at the edge. Think of it as one actor in the actor model, but the runtime handles all the hard parts: routing requests to the right instance, persisting state, and ensuring single-threaded access.\n\nEach Durable Object has:\n\n- A globally unique ID\n- A single-threaded execution context (no concurrency bugs possible)\n- A transactional key-value storage API (strongly consistent)\n- The ability to accept WebSocket connections\n- An alarm system for scheduled work\n\nThe critical property: **only one instance of a given Durable Object exists anywhere in the world at any time**. The platform guarantees this. If two requests for the same object arrive at different [Cloudflare](<https://cloudflare.com/>) PoPs, one waits while the request is routed to wherever that object is currently running.\n\n```typescript\n// wrangler.toml\n[durable_objects]\nbindings = [\n { name = \"GAME_ROOMS\", class_name = \"GameRoom\" }\n]\n\n[[migrations]]\ntag = \"v1\"\nnew_classes = [\"GameRoom\"]\n```"},{"heading":"The Game Room: Actor Model in Practice","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-game-room-actor-model-in-practice","content":"Here is the skeleton of a game server implementation. Each game room is one 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 // CRITICAL: Block all requests until state is loaded\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(\"Missing playerId\", { status: 400 });\n }\n\n this.state.acceptWebSocket(server, [playerId]);\n this.sessions.set(playerId, server);\n\n // Send current state to the new player\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 // Set an alarm to clean up if everyone leaves\n if (this.sessions.size === 0) {\n await this.state.storage.setAlarm(Date.now() + 300_000); // 5 minutes\n }\n }\n\n async alarm() {\n if (this.sessions.size === 0) {\n // No one reconnected, clean up\n await this.state.storage.deleteAll();\n }\n }\n\n // ...\n}\n```\n\nThe beauty here is the absence of complexity. No mutex, no locks, no CAS loops, no optimistic concurrency control. The runtime guarantees that ` webSocketMessage ` calls are serialized. When Player A places a tile and Player B draws tiles simultaneously, they execute one after the other, never interleaved. The actor model gives you this for free."},{"heading":"The Routing Layer: Workers as Matchmakers","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-routing-layer-workers-as-matchmakers","content":"A [Cloudflare Worker](<https://workers.cloudflare.com/>) acts as the entry point, routing players to the right Durable Object:\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 // The ID is deterministic, same roomId always routes to the same DO\n const id = env.GAME_ROOMS.idFromName(roomId);\n const room = env.GAME_ROOMS.get(id);\n\n // This request is forwarded to wherever the DO is running\n return room.fetch(request);\n }\n\n if (url.pathname === \"/matchmake\") {\n return handleMatchmaking(request, env);\n }\n\n // Serve static assets from the edge (or proxy to Vercel)\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 // Check waiting rooms by language\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\nThe key insight: ` idFromName() ` is a deterministic hash. The string ` \"waiting:en:1200\" ` always maps to the same Durable Object ID, so all English-speaking players with ~1200 rating end up in the same matchmaking room without any coordination layer."},{"heading":"State Management: Transactional Storage","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#state-management-transactional-storage","content":"The storage API is deceptively simple but remarkably powerful:\n\n```typescript\nprivate async handlePlaceTile(playerId: string, data: PlaceTileAction) {\n if (this.gameState!.currentTurn !== playerId) {\n this.sendError(playerId, \"Not your turn\");\n return;\n }\n\n const { x, y, letter } = data;\n\n // Validate the move\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 // Apply the move\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 // Persist atomically, if this fails, in-memory state is stale\n // but the next request will reload from storage via blockConcurrencyWhile\n await this.state.storage.put(\"gameState\", this.gameState);\n\n // Broadcast to all players\n this.broadcast({\n type: \"tile_placed\",\n playerId,\n x, y, letter,\n scores: this.calculateScores(),\n });\n}\n```\n\nFor more complex operations, you get transactions:\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\nThis is ACID within a single Durable Object. The writes either all commit or all roll back. No partial updates. No torn reads. In a traditional distributed system, getting this guarantee across multiple keys requires a consensus protocol. Here, it's a one-liner because the single-threaded model makes serializable isolation trivial."},{"heading":"Performance: What I Actually Measured","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#performance-what-i-actually-measured","content":"A load test with 500 simulated players across 50 game rooms yields these results:\n\n| Metric | Value |\n| --- | --- |\n| WebSocket message latency (same region) | 8-15ms |\n| WebSocket message latency (cross-continent) | 40-90ms |\n| State persistence latency | 2-5ms |\n| Cold start (new DO) | 15-25ms |\n| Warm message processing | \\<1ms |\n| Max concurrent WebSockets per DO | ~32,000 (tested) |\n| Storage reads | \\<1ms (cached in-memory after first read) |\n\nThe cross-continent latency deserves explanation. When a player in Tokyo connects to a room that was created by a player in Berlin, the Durable Object is running near Berlin. The Tokyo player's messages route through Cloudflare's backbone to Berlin, get processed, and the response routes back. That's ~80ms round trip. Not instant, but better than a server in us-east-1 for both players.\n\nFor a word game, 80ms is fine, it's a turn-based game, not a twitch shooter. For real-time games like .io games, you'd want to use the location hints API to spawn the DO near the majority of players:\n\n```typescript\nconst id = env.GAME_ROOMS.newUniqueId({\n jurisdiction: \"eu\" // Optional: restrict to EU for GDPR\n});\n\n// Or use locationHint for latency optimization\nconst id = env.GAME_ROOMS.idFromName(roomId);\nconst room = env.GAME_ROOMS.get(id, { locationHint: \"enam\" }); // Eastern North America\n```"},{"heading":"Operational Considerations and Limitations","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#operational-considerations-and-limitations","content":"**Memory limits.** Each Durable Object gets 128 MB of memory. That sounds like a lot until you're holding 1000 WebSocket connections with per-player state. A typical game state serializes to ~2 KB per player, allowing ~500 players per room comfortably. For more, you need to shard rooms.\n\n**No inter-DO communication primitives.** If Room A needs to know about Room B (e.g., for a tournament bracket), you have to go through a Worker fetch. There's no built-in pub/sub between Durable Objects. A simple relay pattern addresses this:\n\n```typescript\n// Tournament coordinator DO\nasync notifyRooms(roomIds: string[], event: TournamentEvent) {\n const promises = roomIds.map(async (roomId) => {\n const id = this.env.GAME_ROOMS.idFromName(roomId);\n const room = this.env.GAME_ROOMS.get(id);\n return room.fetch(new Request(\"https://internal/tournament-event\", {\n method: \"POST\",\n body: JSON.stringify(event),\n }));\n });\n\n await Promise.allSettled(promises);\n}\n```\n\n**Hibernation and cost.** Durable Objects with WebSocket Hibernation are billed only when actively processing messages, not while idle. But every storage operation costs money. Persisting state on every single move can result in $12/day in storage writes during testing. Batching writes to every 500ms cuts that to $0.80/day:\n\n```typescript\nprivate pendingWrite = false;\n\nprivate async scheduleWrite() {\n if (this.pendingWrite) return;\n this.pendingWrite = true;\n\n // Coalesce writes, multiple moves within 500ms become one storage write\n setTimeout(async () => {\n await this.state.storage.put(\"gameState\", this.gameState);\n this.pendingWrite = false;\n }, 500);\n}\n```\n\n**Debugging is rough.** ` wrangler dev ` gives you local Durable Objects, but the behavior differs from production in subtle ways. Storage operations are synchronous locally but asynchronous in production. A common bug is ` storage.put() ` followed by ` storage.get() ` returning stale data in production because the put was not awaited. This works perfectly locally, making it difficult to catch."},{"heading":"Architecture Pattern: Durable Objects + Vercel","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#architecture-pattern-durable-objects-vercel","content":"The final architecture looks like this:\n\n```\n[Players] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]\n ↓\n[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]\n ↓\n[Vercel API Routes (user accounts, leaderboards)]\n```\n\n[Vercel](<https://vercel.com/>) handles the Next.js frontend and the non-realtime API (user profiles, leaderboards, game history). Cloudflare handles the real-time game play. The separation is clean: anything that needs strong consistency and real-time updates goes to Durable Objects; anything that's eventually consistent and request/response goes to Vercel.\n\nThe frontend connects to both:\n\n```typescript\n// Vercel API for account data\nconst profile = await fetch(\"/api/profile\").then(r => r.json());\n\n// Cloudflare Worker for game WebSocket\nconst ws = new WebSocket(\n `wss://game.example.com/game/${roomId}/ws?playerId=${profile.id}`\n);\n```"},{"heading":"When Not to Use Durable Objects","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#when-not-to-use-durable-objects","content":"They're not a general-purpose backend. Specific anti-patterns to avoid:\n\n- **CRUD APIs**: Use a normal database. Durable Objects add latency for simple read/write patterns.\n- **Heavy computation**: 128 MB memory, 30-second CPU time limit. Don't try to run ML inference.\n- **Global aggregation**: If you need to count all active players across all rooms, you need a separate system. Each DO is isolated.\n- **Relational data**: No joins, no indexes, no queries. The storage API is a key-value store.\n\nThey shine for: real-time collaboration, multiplayer games, chat rooms, IoT device coordination, rate limiting, and any scenario where a small group of clients need strongly consistent shared state with low latency."},{"heading":"The Verdict","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#the-verdict","content":"After three months in production with ~2000 daily active players, this type of game server costs roughly $34/month on Cloudflare (Workers + Durable Objects + storage). The equivalent on AWS would be at least two EC2 instances plus an Application Load Balancer plus ElastiCache for session state, probably $150+/month for worse latency outside us-east-1.\n\nMore importantly, zero infrastructure updates are needed. No patching, no scaling configuration, no health checks, no auto-scaling groups. The platform handles all of it. When a game room is active, it runs. When it's idle, it hibernates. When players are on three continents, it still feels responsive.\n\nDurable Objects aren't the answer to everything. But for the specific problem of \"small groups need real-time shared state with strong consistency\", which describes a surprising number of applications, they represent one of the strongest solutions available."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#questions","content":""},{"heading":"How much does a Durable Object cost compared to a traditional server?","level":3,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-1","content":"A Durable Object costs $0.15 per million requests plus $0.50 per GB-month of storage. For a game server handling 1000 concurrent players at 20 ticks/sec, that's roughly $26/month, comparable to a small VPS but distributed across 300+ edge locations with zero infrastructure management."},{"heading":"Can Durable Objects handle real-time game state at 60fps?","level":3,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-2","content":"Not at 60fps server-side tick rate, network latency alone makes that impractical. But 20-30 ticks/sec works well for most multiplayer games. The real advantage is sub-50ms latency to players worldwide since the Durable Object runs in the nearest Cloudflare PoP to the first player who connects."},{"heading":"What happens when a Durable Object crashes mid-game?","level":3,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#question-3","content":"The platform automatically restarts it, and your stored state (via the storage API) survives the crash. In-memory state is lost, so you need to design for recovery. Checkpointing critical game state every 500ms to storage ensures that on restart, clients reconnect and receive the last checkpoint."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#article-tips","content":"1. **Tip:** Always use ` blockConcurrencyWhile() ` in your Durable Object constructor for initialization. Without it, multiple requests can hit your object before the state is loaded, causing race conditions that are impossible to reproduce locally.\n2. **Tip:** Keep your Durable Object storage under 128 KB per key. The storage API is optimized for many small keys, not few large ones. If you need to store a big game state, shard it across keys like ` state:chunk:0 `, ` state:chunk:1 `, etc.\n3. **Tip:** Use the ` alarm() ` API for garbage collection of idle Durable Objects instead of relying on the platform to evict them. Set an alarm on the last activity timestamp, and in the alarm handler, check if the object should self-destruct. This prevents ghost objects from accumulating storage costs.\n\n- cloudflare\n- durable-objects\n- edge-computing\n- websockets\n- actor-model\n- workers\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare"},{"text":"Cloudflare","url":"https://cloudflare.com/"},{"text":"Cloudflare Worker","url":"https://workers.cloudflare.com/"},{"text":"Vercel","url":"https://vercel.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare"},"headline":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","description":"Stateful edge computing with Cloudflare Durable Objects, WebSocket coordination, the actor model at the edge, and a multiplayer game server architecture running in 300+ locations.","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/blog/edge-computing-durable-objects-cloudflare","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Edge Computing","keywords":"cloudflare, durable-objects, edge-computing, websockets, actor-model, workers","wordCount":1739,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/edge-computing-durable-objects-cloudflare"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Cloudflare Durable Objects: Building a Stateful Game Server at the Edge","item":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How much does a Durable Object cost compared to a traditional server?","acceptedAnswer":{"@type":"Answer","text":"A Durable Object costs $0.15 per million requests plus $0.50 per GB-month of storage. For a game server handling 1000 concurrent players at 20 ticks/sec, that's roughly $26/month, comparable to a small VPS but distributed across 300+ edge locations with zero infrastructure management."}},{"@type":"Question","name":"Can Durable Objects handle real-time game state at 60fps?","acceptedAnswer":{"@type":"Answer","text":"Not at 60fps server-side tick rate, network latency alone makes that impractical. But 20-30 ticks/sec works well for most multiplayer games. The real advantage is sub-50ms latency to players worldwide since the Durable Object runs in the nearest Cloudflare PoP to the first player who connects."}},{"@type":"Question","name":"What happens when a Durable Object crashes mid-game?","acceptedAnswer":{"@type":"Answer","text":"The platform automatically restarts it, and your stored state (via the storage API) survives the crash. In-memory state is lost, so you need to design for recovery. Checkpointing critical game state every 500ms to storage ensures that on restart, clients reconnect and receive the last checkpoint."}}]}]}],"contentHash":"f9e9858314747324317ba05c1019e17da4749a0ddcd66f22dd0a9827ef376507","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/edge-computing-durable-objects-cloudflare","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70757,"contentBytes":15947}}
