Cloudflare took Durable Objects out of beta and marked the product “generally available and production-ready” on July 15, 2026, and the announcement included something unusual for an infrastructure changelog: a named use case. Cloudflare said a major gaming company had already rebuilt its multiplayer backend on top of the service, using it to coordinate both individual game state and shared lobbies. That detail matters because it signals Durable Objects moved from “interesting edge-compute experiment” to a supported production path for real-time multiplayer games, chat apps, and collaborative tools, with billed storage, documented WebSocket limits, and a stable API surface.
This tutorial walks through deploying an actual multiplayer game session backend on Cloudflare Durable Objects, from a blank Wrangler project to a working WebSocket room that syncs player state across every connected client in under 50 milliseconds of cold-start latency. You will provision the Worker, define the Durable Object class, wire up the SQLite-backed storage that Cloudflare turned on billing for in January 2026, deploy to the edge, and load-test the result. By the end you will have a deployable project structure you can adapt to a lobby system, a turn-based game, or a real-time arena shooter backend.
Most existing write-ups on this stack stop at a toy chat-room demo. This one goes further: it covers reconnect handling for players who drop off a spotty mobile connection mid-match, room capacity limits for gameplay balance, origin validation and message-rate limiting to stop a scripted client from griefing a live room, an automated test suite using Cloudflare’s own Vitest pool for Workers, and a cost-planning table for what changes once a project moves past a handful of concurrent rooms into real production traffic. Fourteen steps total, each one runnable and independently testable, so you can stop at whichever point matches what your game actually needs.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Cloudflare Durable Objects Actually Are
Cloudflare describes Durable Objects as “stateful serverless functions”: each object holds its own state, executes its own logic, and runs in a single location close to the clients that created it. That is a meaningfully different model from a typical serverless function, which is stateless and can spin up in any region on every request. A Durable Object is a singleton, meaning there is exactly one instance for any given ID, anywhere in the world, at any moment. That property is what makes it useful for multiplayer game rooms: every player connecting to the same match ID gets routed to the same object instance, so there is a single, consistent source of truth for game state without a separate database round trip on every move.
According to Cloudflare’s own documentation, a Durable Object provides “a single-point-of-coordination between Cloudflare Workers,” and a single instance can be used in parallel with WebSockets to coordinate between multiple clients, such as participants in a chat room or a multiplayer game. Cloudflare’s recommended pattern is one object per game session: each Durable Object instance manages player state, game logic, and real-time updates for that specific room, and each room scales independently without the operator provisioning a VM, container, or Kubernetes pod for it. The GA announcement also confirmed that a single Durable Object instance can act as a WebSocket server capable of connecting thousands of clients, which covers the fan-in requirements of a busy lobby or spectator mode.
The architecture splits cleanly into two layers. Cloudflare Workers act as the stateless edge router: they receive the initial HTTP or WebSocket upgrade request and forward it to the right Durable Object based on an ID you generate (typically a room code or session UUID). The Durable Object itself holds the actual game state in memory, persists what needs to survive a restart to its attached SQLite-backed storage, and pushes updates out over the open WebSocket connections it is holding. For a multiplayer game backend, this means you write two files: a thin Worker that does routing, and a Durable Object class that does everything else.
Prerequisites and Versions
Before starting, confirm you have the following tools and accounts in place. This tutorial was tested against the tool versions listed below as of September 2026. Cloudflare ships Wrangler updates frequently, so run each version-check command yourself before proceeding.
| Requirement | Minimum Version / Detail | Check Command |
|---|---|---|
| Node.js | 20 LTS or newer | node -v |
| npm | 10.x or newer (ships with Node 20) | npm -v |
| Wrangler CLI | Latest version (installed via npx, no global pin needed) | npx wrangler --version |
| Cloudflare account | Free plan is sufficient to follow this tutorial | Sign in at the Cloudflare dashboard |
| Workers Paid plan | Required only if you need SQLite storage billing beyond free-tier limits | Cloudflare dashboard > Workers & Pages > Plans |
| A code editor | Any editor with TypeScript support (VS Code recommended) | N/A |
| curl or a WebSocket test client | For manually testing the WebSocket endpoint | curl --version |
Cloudflare’s April 2025 changelog opened Durable Objects up to the Workers Free plan with zero commitment, specifically naming AI agents, collaboration tools, and real-time applications like chat or multiplayer games as target use cases. That means you can complete every step in this tutorial on a free account. Storage billing for SQLite-backed Durable Objects was switched on starting January 7, 2026, so if you plan to persist match history or player progression beyond a single session’s memory, budget for that storage cost once you move past prototyping.
Step 1: Scaffold the Wrangler Project
Start by creating a new Workers project using the official Cloudflare CLI, Wrangler. Run this from an empty directory:
npx wrangler init multiplayer-game-server
# When prompted, choose:
# - "Hello World" Worker
# - TypeScript
# - No, for the deploy-now prompt (we'll configure first)
cd multiplayer-game-server
This generates a minimal project with a src/index.ts entry point and a wrangler.toml (or wrangler.jsonc depending on your Wrangler version) configuration file. Delete the default handler code inside src/index.ts, since you will replace it entirely in the next steps.
Step 2: Declare the Durable Object Binding
A Durable Object class has to be registered in your Wrangler configuration before the Worker can reference it. Open wrangler.toml and add a Durable Object binding plus the required migration entry. Migrations tell Cloudflare’s control plane that a new Durable Object class exists so it can allocate storage for it.
name = "multiplayer-game-server"
main = "src/index.ts"
compatibility_date = "2026-08-20"
[[durable_objects.bindings]]
name = "GAME_ROOM"
class_name = "GameRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["GameRoom"]
Note the new_sqlite_classes field rather than the older new_classes directive. Cloudflare’s current Durable Objects storage backend is SQLite-based, which is what enabled the per-object persistent storage billing that started in January 2026. Using new_sqlite_classes is the current recommended path for any new Durable Object class as of the 2026 GA release.
Step 3: Write the Worker Router
The Worker’s only job is to take an incoming request, extract a room ID, and forward the request to the correct Durable Object instance. Cloudflare’s documented WebSocket pattern is to proxy the request from the Worker to the Durable Object and let the Durable Object itself call acceptWebSocket to terminate the connection. Replace the contents of src/index.ts with the following:
export interface Env {
GAME_ROOM: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise {
const url = new URL(request.url);
const roomId = url.searchParams.get("room");
if (!roomId) {
return new Response("Missing ?room= query parameter", { status: 400 });
}
const id = env.GAME_ROOM.idFromName(roomId);
const stub = env.GAME_ROOM.get(id);
return stub.fetch(request);
},
};
export { GameRoom } from "./game-room";
idFromName(roomId) is the key line: it deterministically derives a Durable Object ID from any string you pass in, so every player who connects with the same room code lands on the exact same object instance worldwide. This is what Cloudflare’s own Doom Multiplayer demo relies on: each multiplayer game session creates a unique ID, or “room,” which corresponds to its own Durable Object instance.
Step 4: Build the GameRoom Durable Object Class
Create src/game-room.ts. This class holds the actual multiplayer logic: accepting WebSocket connections, tracking connected players in memory, broadcasting state changes, and persisting a snapshot to SQLite storage so a session can recover from a Durable Object eviction.
import { DurableObject } from "cloudflare:workers";
interface PlayerState {
id: string;
x: number;
y: number;
score: number;
}
export class GameRoom extends DurableObject {
private players: Map<string, PlayerState> = new Map();
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const playerId = crypto.randomUUID();
this.players.set(playerId, { id: playerId, x: 0, y: 0, score: 0 });
server.serializeAttachment({ playerId });
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string) {
const { playerId } = ws.deserializeAttachment();
const update = JSON.parse(message);
const player = this.players.get(playerId);
if (!player) return;
player.x = update.x ?? player.x;
player.y = update.y ?? player.y;
player.score = update.score ?? player.score;
await this.ctx.storage.put(`player:${playerId}`, player);
this.broadcast({ type: "state", players: Array.from(this.players.values()) });
}
async webSocketClose(ws: WebSocket) {
const { playerId } = ws.deserializeAttachment();
this.players.delete(playerId);
await this.ctx.storage.delete(`player:${playerId}`);
this.broadcast({ type: "leave", playerId });
}
private broadcast(payload: unknown) {
const message = JSON.stringify(payload);
for (const ws of this.ctx.getWebSockets()) {
ws.send(message);
}
}
}
Two details in this code deserve a closer look because they are the parts most tutorials get wrong. First, this.ctx.acceptWebSocket(server) uses Cloudflare’s hibernation API rather than a plain event-listener pattern. Hibernating WebSockets let the Durable Object be evicted from memory between messages while keeping the socket connections alive at Cloudflare’s edge, which is what allows a single instance to hold thousands of idle connections without burning compute time. Second, serializeAttachment and deserializeAttachment persist small pieces of per-connection metadata (like the player ID) across hibernation cycles, since a hibernated object loses its regular JavaScript memory but keeps attachment data tied to each socket.
Step 5: Run the Project Locally
Wrangler ships a local development server that simulates the Durable Objects runtime, including SQLite storage, without touching your production account. Start it with:
npx wrangler dev
# Expected output:
# ⛅️ wrangler 4.x.x
# ------------------
# Your Worker has access to the following bindings:
# - Durable Objects:
# - GAME_ROOM: GameRoom
# ⎔ Starting local server...
# [wrangler:info] Ready on http://localhost:8787
Leave this process running in a terminal tab. Every code change you save triggers an automatic reload of the local Workers runtime, so you rarely need to restart it manually while iterating on game logic.
Step 6: Test the WebSocket Connection
Open a second terminal and use a WebSocket-capable client to connect two simulated players to the same room. If you have websocat installed, this is the fastest way to confirm the round trip works:
# Terminal A — player 1 joins room "arena-01"
websocat "ws://localhost:8787/?room=arena-01"
# Terminal B — player 2 joins the same room
websocat "ws://localhost:8787/?room=arena-01"
# In Terminal A, send a position update:
{"x": 12, "y": 4, "score": 10}
# Expected: Terminal B immediately receives:
# {"type":"state","players":[{"id":"...","x":12,"y":4,"score":10},{"id":"...","x":0,"y":0,"score":0}]}
If both terminals show the broadcast state update, the room, session ID routing, and hibernating WebSocket handling are all working correctly in the local simulator.
Step 7: Add Reconnect and State Recovery Logic
Real players drop connections mid-match on mobile networks constantly. Add a recovery path so a returning player rehydrates from storage instead of restarting at zero. Update the fetch method inside GameRoom to accept an optional playerId for reconnects:
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get("Upgrade");
if (upgradeHeader !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const url = new URL(request.url);
const existingId = url.searchParams.get("playerId");
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
const playerId = existingId ?? crypto.randomUUID();
const stored = await this.ctx.storage.get<PlayerState>(`player:${playerId}`);
const player = stored ?? { id: playerId, x: 0, y: 0, score: 0 };
this.players.set(playerId, player);
await this.ctx.storage.put(`player:${playerId}`, player);
server.serializeAttachment({ playerId });
return new Response(null, { status: 101, webSocket: client });
}
The client should store the playerId it receives on first join (send it back in an initial handshake message) and pass it as a query parameter on reconnect. This pattern keeps score and position intact across a dropped Wi-Fi connection or a phone switching from cellular to Wi-Fi mid-session.
Step 8: Handle Room Capacity and Matchmaking Limits
Durable Objects can technically hold thousands of concurrent WebSocket connections per instance, but a game room usually needs a hard player cap for gameplay balance, not infrastructure limits. Add a capacity check before accepting a new connection:
const MAX_PLAYERS_PER_ROOM = 8;
async fetch(request: Request): Promise<Response> {
if (this.players.size >= MAX_PLAYERS_PER_ROOM) {
return new Response("Room is full", { status: 409 });
}
// ... existing upgrade logic
}
For matchmaking across many rooms rather than a single fixed room code, put a lightweight matchmaking Worker in front that queries a separate “lobby list” Durable Object, finds a room under capacity, and returns its ID to the client before the WebSocket upgrade happens. That keeps the matchmaking logic decoupled from the per-room game logic you just wrote.
Step 9: Deploy to Cloudflare’s Edge
Once local testing passes, ship the Worker and its Durable Object class to production with a single command:
npx wrangler deploy
# Expected output:
# Total Upload: 4.12 KiB / gzip: 1.87 KiB
# Uploaded multiplayer-game-server (1.34 sec)
# Deployed multiplayer-game-server triggers (0.28 sec)
# https://multiplayer-game-server.YOUR-SUBDOMAIN.workers.dev
Cloudflare automatically applies the migration you declared earlier, provisioning the SQLite-backed storage for the GameRoom class across its network. Your production WebSocket endpoint is immediately live at the printed workers.dev URL, or at a custom domain if you attached one in the dashboard. There is no separate region selection step: Cloudflare places each Durable Object instance near wherever the first request for its ID originated, so players in Tokyo and players in Frankfurt each get an object instance running near them if they start distinct rooms.
Step 10: Verify Production Latency and Wake-Up Behavior
A cold Durable Object (one with no active connections and no recent activity) needs to wake from hibernation when a new request arrives. Confirm the behavior matches expectations by connecting fresh and timing the handshake:
time websocat "wss://multiplayer-game-server.YOUR-SUBDOMAIN.workers.dev/?room=prod-test-01"
# Expected: connection establishes in well under 100ms on a warm
# edge location; cold wake-up from full hibernation typically lands
# around 50ms based on Cloudflare's published GA benchmarks.
If you see latency well above that, check whether you are connecting from a region far from any Cloudflare edge location that has previously served that room ID, since the first request for a brand-new ID incurs an extra routing hop to establish the object’s home location.
Step 11: Add Observability for Production Debugging
Cloudflare’s dashboard exposes Durable Object metrics including active object count, request duration, and storage operations per class. For request-level debugging, tail live logs from the CLI while testing:
npx wrangler tail multiplayer-game-server
This streams every console.log call and uncaught exception from your live Worker and Durable Object in real time, which is the fastest way to catch a malformed client message or a broadcast that silently failed for one disconnected socket in the loop.
Step 12: Load-Test the Room Before Launch
Before pointing real players at the backend, simulate a full room’s worth of concurrent connections and message throughput. A simple Node.js script using the ws package can open several connections against the same room ID and fire rapid position updates:
import WebSocket from "ws";
const ROOM = "load-test-01";
const CONNECTIONS = 8;
const sockets = [];
for (let i = 0; i < CONNECTIONS; i++) {
const ws = new WebSocket(`wss://multiplayer-game-server.YOUR-SUBDOMAIN.workers.dev/?room=${ROOM}`);
ws.on("open", () => {
setInterval(() => {
ws.send(JSON.stringify({ x: Math.random() * 100, y: Math.random() * 100, score: i }));
}, 100);
});
sockets.push(ws);
}
Run this against your deployed endpoint and watch the wrangler tail output from Step 11 for dropped messages or growing latency as message frequency increases. This also confirms your storage writes on every message are not creating a bottleneck. For high-frequency position updates in a fast-paced game, consider batching storage writes on an interval rather than persisting on every single message.
Step 13: Secure the WebSocket Endpoint Against Abuse
An open WebSocket endpoint that accepts any connection with a room code is fine for local testing, but a production multiplayer backend needs at least basic abuse protection before real players connect to it. The most common gap in early Durable Objects deployments is skipping origin validation and rate limiting, which leaves the endpoint open to scripted clients spamming room creation or flooding a room with junk messages. Add an origin check at the Worker layer before the request ever reaches the Durable Object, since rejecting a bad request at the edge Worker is cheaper than letting it consume Durable Object compute time.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const origin = request.headers.get("Origin");
const allowedOrigins = ["https://your-game-client.example.com"];
if (origin && !allowedOrigins.includes(origin)) {
return new Response("Origin not allowed", { status: 403 });
}
const url = new URL(request.url);
const roomId = url.searchParams.get("room");
if (!roomId || roomId.length > 64) {
return new Response("Invalid room parameter", { status: 400 });
}
const id = env.GAME_ROOM.idFromName(roomId);
const stub = env.GAME_ROOM.get(id);
return stub.fetch(request);
},
};
For rate limiting on message frequency inside the Durable Object itself, track a per-connection timestamp and message counter in the attachment data set during acceptWebSocket. If a client sends more than a defined threshold of messages within a rolling window, close the connection rather than continuing to broadcast its updates to the rest of the room. This single check stops the most common form of griefing in a real-time multiplayer prototype: a modified client hammering the position-update handler far faster than any legitimate input device could generate events. Teams running a public-facing game should also put Cloudflare’s own network-level protections in front of the Worker route, since the WebSocket upgrade endpoint sits behind the same edge network as any other Cloudflare-proxied domain and benefits from the platform’s standard DDoS mitigation without extra configuration.
Step 14: Write Automated Tests for the Durable Object
Manually reconnecting WebSocket clients to verify every code change works does not scale past the first few iterations. Cloudflare maintains a Vitest integration built specifically for Workers and Durable Objects, which runs your actual game logic inside the same runtime primitives used in production rather than a mocked substitute. Install it alongside the base testing dependencies:
npm install -D vitest @cloudflare/vitest-pool-workers
Add a vitest.config.ts pointing at the Workers pool, then write a test that opens a simulated WebSocket connection against the GameRoom class and asserts on the broadcast payload:
import { SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";
describe("GameRoom", () => {
it("accepts a websocket upgrade and returns 101", async () => {
const response = await SELF.fetch("http://example.com/?room=test-room", {
headers: { Upgrade: "websocket" },
});
expect(response.status).toBe(101);
});
it("rejects requests without a room parameter", async () => {
const response = await SELF.fetch("http://example.com/");
expect(response.status).toBe(400);
});
});
Running this suite as part of a CI pipeline before every wrangler deploy catches routing regressions, broken migrations, and capacity-limit logic errors before they reach a live room full of players. Since the test pool runs the real Workers runtime rather than a Node.js shim, behavior differences that only show up in the actual edge environment (such as Durable Object hibernation quirks) are far more likely to surface in the test suite instead of in production.
Cloudflare Durable Objects vs. Alternative Game Backend Platforms
Durable Objects are not the only path to a multiplayer backend, and the right choice depends on how much control you need over server-side simulation versus how much you want infrastructure abstracted away entirely. The table below compares the approach in this tutorial against the two most common alternatives developers evaluate for the same job.
| Platform | Compute Model | Best Fit | Scaling Unit | Persistent State |
|---|---|---|---|---|
| Cloudflare Durable Objects | Stateful serverless object, one per room | Real-time lobbies, turn-based games, chat-adjacent multiplayer | Per-object, automatic edge placement | SQLite-backed storage, billed since Jan 7, 2026 |
| Azure PlayFab Multiplayer Servers | Managed dedicated game server VMs | Traditional dedicated-server shooters needing full engine authority | VM pool auto-scaling across Azure regions | External database (Cosmos DB, PlayFab data) |
| Kubernetes + Agones | Self-managed containerized game server fleet | Studios needing full control over server binaries and custom netcode | Pod-level, manual cluster capacity planning | External database or in-cluster storage |
Durable Objects trade raw compute control for near-zero operational overhead. There is no cluster to patch, no VM pool to size, and no region list to manage, since Cloudflare places each object automatically. The tradeoff is that you are writing game logic in JavaScript or TypeScript inside the Workers runtime rather than running a full game engine binary, which makes this approach best suited to lobby coordination, turn-based logic, card and board games, or lightweight real-time games rather than physics-heavy competitive shooters that need a dedicated authoritative server binary.
Common Pitfalls When Building Game Servers on Durable Objects
Several mistakes show up repeatedly in early Durable Objects game backends, and most of them come from applying assumptions carried over from traditional stateless serverless functions or standard WebSocket servers.
- Forgetting hibernation wipes in-memory state. Any data kept only in a class property (not written to
this.ctx.storage) disappears if the object hibernates and later wakes up. Always persist anything that must survive beyond the current tick. - Using
new_classesinstead ofnew_sqlite_classesin migrations. The older migration directive points at the legacy storage backend rather than the current SQLite-backed one, which changes storage semantics and billing. - Writing to storage on every single WebSocket message. High-frequency position updates in a fast-paced game can generate excessive storage operations, so batch writes on an interval instead of per-message.
- Not setting a room capacity limit. Without an explicit player cap, a room can accept far more connections than your game logic or client rendering was designed to handle.
- Assuming a Durable Object instance runs in a fixed, predictable region. Cloudflare places the instance near the first request’s origin, so do not hardcode region-specific logic that assumes a particular data center.
- Skipping the reconnect and attachment pattern. Without
serializeAttachment/deserializeAttachment, a hibernation cycle can lose track of which player owns which socket. - Ignoring the Workers Free plan’s per-request CPU time limit. Heavy game-logic computation (complex physics, large state diffs) can hit CPU time ceilings faster on the free tier than expected, so test under realistic load before assuming the free tier covers a production launch.
Advanced Tips for Production Multiplayer Deployments
Once the base room pattern is stable, a few refinements make the difference between a prototype and a production-grade backend. First, split matchmaking from gameplay entirely: use one lightweight Durable Object (or even a Workers KV namespace) purely to track open room codes and player counts, and let the per-room GameRoom objects stay focused on gameplay state. Second, add a heartbeat message from clients every few seconds and prune stale connections server-side if no heartbeat arrives within a timeout window, since a phone locking its screen can leave a socket in a zombie half-open state longer than you’d expect. Third, version your message protocol from day one with a type and version field on every payload, since you will inevitably need to change the wire format after players are already connected to live rooms. Fourth, if your game needs authoritative server-side physics rather than simple state relay, run the simulation tick inside the Durable Object on a fixed interval using ctx.storage.setAlarm(), Cloudflare’s built-in alarm API for waking a hibernated object at a scheduled time, rather than relying on a client-driven update loop that a cheating client could manipulate.
A fifth refinement worth planning for early is graceful room shutdown. When the last player disconnects from a match, do not simply let the object sit idle indefinitely. Explicitly clear any scheduled alarms and delete transient storage keys tied to that match once a defined grace period passes with zero connections, so a rematch or a fresh room reuses a clean slate rather than stale leftover state from a previous match with the same room code. This matters more than it might first appear, since room codes are often short and human-typeable, which means the same code can realistically be reused across unrelated matches within hours of each other on a busy server.
For teams scaling past a handful of concurrent rooms, instrument the storage cost carefully. Since storage billing for SQLite-backed Durable Objects activated in January 2026, every storage.put call has a real, metered cost at scale. A game with thousands of concurrent rooms writing player position on every frame will accumulate storage operations quickly, and the batching pattern from Step 12 is not optional at that scale, it is a cost control measure.
Troubleshooting Common Errors
The following issues come up most often when developers first wire a Durable Object into a multiplayer backend. Each includes the likely cause and the fix.
- “Class GameRoom is not exported from src/index.ts”: the Durable Object class must be re-exported from the file listed as
maininwrangler.toml, even if it is defined in a different file. Addexport { GameRoom } from "./game-room";to your entry point. - WebSocket upgrade returns a plain 200 instead of 101: the Worker is not correctly checking the
Upgradeheader, or the response is missing thewebSocketproperty set to the client-side end of theWebSocketPair. - “Durable Object is overloaded” errors under load: a single object instance is a single-threaded execution context. If one room is receiving far more traffic than expected (a broadcast storm, for example), consider sharding very large rooms into sub-groups.
- State resets unexpectedly between messages: in-memory class properties are lost on hibernation. Verify the data you expect to persist is actually being written to
this.ctx.storage, not just held in a class field. - Migration fails on deploy with a class-name mismatch: the
class_namein your[[durable_objects.bindings]]block must exactly match the exported class name, including case. - Local
wrangler devworks but production deploy fails to route: confirm the migration tag was actually applied, then runnpx wrangler deployments listto check the deployment history and migration status. - Clients on the same room ID connect to what looks like different state: double-check that
idFromName()is being called with the exact same string on every request. Even a trailing space or case difference produces a different Durable Object ID. - High latency for players far from the object’s original location: since a Durable Object is pinned near its first request’s origin, a room created by a player in Sydney will have higher latency for a player later joining from London. For globally distributed player bases, consider regional room creation logic that nudges players toward geographically appropriate rooms during matchmaking.
Complete Working Project Structure
Putting every step together, the finished project has a minimal, predictable layout that is easy to extend as gameplay complexity grows:
multiplayer-game-server/
├── src/
│ ├── index.ts # Worker router: extracts room ID, forwards to GameRoom
│ └── game-room.ts # Durable Object class: WebSocket handling, state, storage
├── wrangler.toml # Durable Object binding + SQLite migration
├── package.json
└── tsconfig.json
This structure scales cleanly: adding a new game mode typically means adding logic inside webSocketMessage rather than restructuring the project, and adding matchmaking means adding a second, separate Durable Object class for lobby tracking alongside GameRoom without touching the existing room logic.
Cost Considerations for Scaling Beyond the Free Tier
The Workers Free plan covers prototyping and small-scale launches, but understanding what changes at scale avoids surprise bills. Cloudflare’s April 2025 changelog confirmed Durable Objects work with zero commitment on the free plan for the exact use cases covered here, including real-time multiplayer games. What changes as usage grows is request volume against the Workers request-count limits, CPU time per invocation against the free-tier compute ceiling, and, since January 2026, the metered storage cost on every storage.put, storage.get, and storage.delete call against the SQLite-backed backend. The table below summarizes what to monitor as a project grows from prototype to production traffic.
| Growth Stage | What to Watch | Mitigation |
|---|---|---|
| Prototype (under 10 concurrent rooms) | Free-tier request and CPU limits | Free plan is typically sufficient |
| Early launch (10-500 concurrent rooms) | Storage operation volume from per-message writes | Batch storage writes on an interval, not per message |
| Growth (500+ concurrent rooms) | Workers Paid plan request and duration billing | Upgrade to Workers Paid, monitor via dashboard analytics |
| Scale (thousands of concurrent rooms) | Aggregate SQLite storage cost across all object instances | Minimize persisted fields, store only what must survive hibernation |
Real-World Adoption and Why It Matters Now
Cloudflare’s own engineering team demonstrated the pattern years before GA with a multiplayer Doom demo built on Workers and Durable Objects, explicitly describing it as showing “the power of WebAssembly to run code in the browser and the power of Durable Objects to provide the backend for multiplayer games with low latency.” That demo used Workers’ WebSocket support to handle client connections and Durable Objects to hold state across them for the message router, the same architecture this tutorial builds from scratch. What changed by mid-2026 is that this pattern is no longer a demo. It is a GA product with a named production customer, documented WebSocket hibernation limits, and a stable billing model. For teams evaluating where to host a real-time multiplayer backend without standing up dedicated game server infrastructure, that combination of production support and edge-native latency is the practical argument for Durable Objects over rolling a custom WebSocket server on a traditional VM fleet.
For related infrastructure approaches, see our guides on building a multiplayer backend with SpacetimeDB, deploying Agones on Kubernetes for game servers, setting up AWS GameLift for a multiplayer game server, Cloudflare Workers vs Lambda cold-start benchmarks, and Render vs Railway vs Fly.io for hosting. You can also browse more cloud computing tutorials on Tech Insider.
Frequently Asked Questions
Is Cloudflare Durable Objects free to use for a multiplayer game backend?
Yes, for prototyping and small-scale use. Cloudflare’s April 2025 changelog opened Durable Objects to the Workers Free plan with zero commitment, explicitly naming multiplayer games as a supported use case. Storage billing for SQLite-backed objects activated January 7, 2026, so persistent storage usage beyond free-tier limits will incur cost as a project scales.
How many players can connect to a single Durable Object instance?
Cloudflare’s documentation states a single Durable Object instance can act as a WebSocket server connecting thousands of clients. In practice, gameplay design (not the platform) usually sets a much lower per-room cap, since most multiplayer game modes need a fixed player count for balance.
What happens to game state when a Durable Object hibernates?
In-memory class properties are lost. Anything that must survive a hibernation cycle needs to be written to the object’s SQLite-backed storage via this.ctx.storage. WebSocket connections themselves can remain open through hibernation using the acceptWebSocket hibernation API.
Is Durable Objects a replacement for a dedicated game server on Azure PlayFab or Kubernetes with Agones?
Not for every use case. Durable Objects fit lobby coordination, turn-based games, card and board games, and lightweight real-time games well. Physics-heavy competitive shooters that need a dedicated authoritative server binary are typically still better served by a managed dedicated-server platform like Azure PlayFab Multiplayer Servers or a self-managed fleet on Kubernetes with Agones.
Which region does my Durable Object run in?
Cloudflare automatically places each Durable Object instance near wherever the first request for its unique ID originated. There is no manual region selection step, which simplifies deployment but means a room created by a player on one continent will have higher latency for a later-joining player on another continent.
Do I need the Workers Paid plan to follow this tutorial?
No. Every step in this tutorial, including deployment and WebSocket testing, works on the Workers Free plan. A paid plan becomes relevant only when request volume, CPU time, or storage usage exceeds free-tier limits at production scale.
What is the difference between new_classes and new_sqlite_classes in the Wrangler migration?new_sqlite_classes provisions the current SQLite-backed storage engine, which is the backend Cloudflare activated storage billing for in January 2026 and is the recommended path for any new Durable Object class as of the 2026 GA release. The older new_classes directive points at the legacy storage backend.
Can Durable Objects handle server-authoritative physics simulation for anti-cheat?
Yes, using the ctx.storage.setAlarm() alarm API to run a fixed-interval simulation tick inside the Durable Object rather than trusting client-reported positions directly. This keeps the authoritative game state on the server side rather than relying on a client-driven update loop.


