Building Real-Time Systems with Cloudflare Durable Objects
Real-time applications are often introduced as a WebSocket problem. Open a connection, broadcast messages, and the system is done.
That model works until multiple users can change the same state, clients disconnect, timers expire, or a client sends an invalid command. At that point, the hard problem is no longer message delivery. It is deciding who owns the truth.
In this article, I use GuessTogether, a multiplayer guessing game, to explain how Cloudflare Durable Objects can provide that authority for real-time rooms.
The same ideas apply to multiplayer games, chat rooms, collaborative editors, live polls, presence systems, and shared dashboards.
Try GuessTogether
GuessTogether is the multiplayer guessing game I built to explore real-time coordination using Cloudflare Durable Objects.
- Live demo: Try GuessTogether
- Source code: View the project on GitHub
The real problem is coordination
- What happens if two players submit at the same time?
- Who decides when the timer ends?
- How do we prevent a client from submitting twice?
- How do we hide the answer until the round is complete?
- What happens when the host refreshes the browser?
- What happens when a player disconnects during a round?
- How does a new browser receive the current state after reconnecting?
These are value ownership problems. A WebSocket only gives us a communication channel, but it does not decide which event/message is valid or which state is incorrect.
A naive architecture
A first attempt might look like this:
Client A ─┐
Client B ─┼─> Stateless API instances ─> Shared database
Client C ─┘This can work, but real-time coordination adds infrastructure quickly:
- Concurrent mutations need transactions or locks.
- Broadcasts need a pub/sub system.
- Timers need a reliable server-side scheduler.
- Reconnecting clients need a way to recover state.
- Stale connections need to be removed safely.
We can store the data in a database, but it does not automatically become the owner of an active real-time session. The application still needs a coordination model around it.
The room-owner model
GuessTogether uses a different partitioning strategy:
Room AB12C ─> Durable Object AB12C
Room XY789 ─> Durable Object XY789
Room QP456 ─> Durable Object QP456Each logical room has one owner. The owner validates commands, updates state, persists it, and broadcasts the new state to connected clients.
Cloudflare Durable Objects are a natural fit for this model because an object has a stable identity, can hold state, has storage, supports WebSockets, and can run scheduled alarms.
The room code becomes the partition key:
const id = env.ROOMS.idFromName(roomCode);
const room = env.ROOMS.get(id);
return room.fetch("https://room/state");The same room code always maps to the same logical Durable Object. Different room codes map to different objects, so independent rooms can operate independently.
The system architecture
React client
The client:
- Renders server snapshots.
- Stores the player identity in localStorage.
- Sends commands over WebSockets.
- Manages UI state with Zustand.
- Uses TanStack Query for HTTP operations such as room creation.
- Reconnects when the socket closes.
- The client does not own authoritative scores, answers, timers, or host permissions.
Cloudflare Worker
The top-level Worker is intentionally small. It:
- Serves the React build through the assets binding.
- Creates random room codes.
- Routes room requests.
- Forwards WebSocket upgrades to the matching Durable Object.
It is a stateless edge entry point, not the owner of live room state.
Room Durable Object
The Durable Object owns:
- The room phase.
- The player list.
- The host ID.
- The current question.
- The round deadline.
- Submitted guesses.
- Scores.
- Connected Web-Sockets.
- Host migration and re-connection behavior.
Durable Object Storage
Storage allows the object to restore the room after a restart, hibernation, or period with no active connections.
Designing the room state
A simplified room state looks like this:
type RoomState = {
code: string;
hostId: string;
phase: "lobby" | "active" | "results" | "final";
players: Player[];
round: number;
questionId: string;
roundEndsAt?: number;
guesses: Record<string, number>;
};The internal state is not sent directly to clients. The Durable Object creates a phase-aware snapshot.
During an active round, a client receives something like:
{
phase: "active",
question: {
prompt: "How many ...?"
},
roundEndsAt: 1760000000000
}It does not receive answer, after the round closes, the server can include the answer, submitted guesses, and round scores in the results snapshot.
This is an important security boundary. A client should receive only the subset of state that is valid for its current phase.
Clients send commands and the server mutates the state
The client-to-server protocol is command-based:
type ClientCommand =
| { type: "join"; playerId: string; name: string }
| { type: "submit_guess"; value: number }
| { type: "start_game" }
| { type: "next_round" }
| { type: "leave" };The server-to-client protocol contains events or snapshots:
type ServerEvent =
| { type: "state"; state: RoomSnapshot }
| { type: "error"; message: string };The client does not send a new leaderboard and ask the server to accept it. It sends an action such as submit_guess, and the Durable Object decides whether that action is valid.
Get Kartikg’s stories in your inbox
Join Medium for free to get updates from this writer.
The complete synchronization flow is:
Client sends command
↓
Durable Object validates command
↓
Object mutates authoritative state
↓
Object persists state
↓
Object broadcasts a new snapshot
↓
Clients replace their local viewClients synchronize through the room authority. They do not synchronize directly with each other.
WebSocket
A WebSocket provides a persistent, bidirectional connection:
Browser ───────── WebSocket ───────── Durable ObjectThat connection is useful for pushing updates without polling.
For GuessTogether, the WebSocket handler is responsible for parsing a message and passing it to the room command handler. The command handler checks the current phase, the player, host permissions, and whether the action has already happened.
Authoritative scoring
The browser never calculates the authoritative score. It may display a preview, but the Durable Object owns the result.
The server-side scoring process is:
- Collect valid guesses.
- Calculate each distance from the answer.
- Find the closest submitted guess.
- Award the maximum score to the closest player.
- Calculate relative scores for the remaining players.
- Persist the updated totals.
- Broadcast the results snapshot.
A simplified version looks like this:
const distances = guesses.map((guess) => ({
playerId: guess.playerId,
distance: Math.abs(guess.value - answer),
}));
const closestDistance = Math.min(
...distances.map((entry) => entry.distance),
);
for (const entry of distances) {
const points =
entry.distance === closestDistance
? MAX_ROUND_SCORE
: calculateRelativeScore(entry.distance, closestDistance);
players[entry.playerId].score += points;
}The server also enforces one submission per round:
if (player.guess !== undefined) {
throw new Error("Guess already submitted");
}This prevents duplicate submissions even if a user clicks twice or sends a custom WebSocket message.
Timers are server-owned
A browser countdown is only a visual aid. Browser timers can be throttled, paused, modified, or lost when a tab closes.
The Durable Object stores an absolute deadline and schedules an alarm:
room.roundEndsAt = Date.now() + ROUND_DURATION_MS;
await this.state.storage.setAlarm(room.roundEndsAt);When the alarm runs, the object checks that the room is still active and that the deadline has passed. It then closes the round, calculates scores, and broadcasts results.
The client calculates the visible countdown from roundEndsAt, but it does not decide when the round ends.
This pattern is useful in few other places as well:
- Auction expiration.
- Poll closing.
- Reservation timeout.
Reconnection
A socket connection is temporary. A player identity is not.
The browser creates a stable local ID:
const storedId = localStorage.getItem("guess-together-player-id");
const playerId = storedId ?? crypto.randomUUID();
localStorage.setItem("guess-together-player-id", playerId);When a player refreshes:
1. The browser keeps the same player ID.
2. The client opens a new WebSocket.
3. The Durable Object matches that ID to the existing player.
4. The old socket is replaced or marked disconnected.
5. The current snapshot is sent.
6. The player keeps their name, score, and host status.
Presence and identity are different concepts. A disconnected socket does not necessarily mean the user has left the room permanently.
Host migration
Immediate host migration creates bad UX during refreshes. The host may be disconnected for only a few seconds, that’s why we use a grace period:
Host disconnects
↓
Start grace timer
↓
Host reconnects
├─ yes: preserve host
└─ no: transfer host to first connected playerThe migration policy is server-side and deterministic. A client cannot claim host privileges by changing local state.
Persistence and recovery
The room keeps active data in memory for fast access and persists state after meaningful mutations.
Memory Durable Object Storage
------- ----------------------
Fast reads Recovery after restart
Socket handling Room snapshot
Current command Round deadlinePersist values such as:
- Room phase.
- Players and scores.
- Host ID.
- Current round.
- Question selection.
- Guesses.
- Round deadline.
On construction, the Durable Object loads the stored room state. When a player reconnects, the object sends a fresh snapshot instead of relying on the client to reconstruct the game.
Scaling by room
The room code creates a natural scaling boundary:
100 rooms × 8 players
= 100 independent coordination objectsAdvantages:
- One room’s updates do not lock another room.
- Broadcasts stay bounded to room members.
- State is naturally partitioned.
- One room’s failure does not corrupt another room.
- The room code is both a routing key and a storage key.
This does not mean a single Durable Object is infinitely scalable. One extremely large room can still become a bottleneck because its state transitions and broadcasts are coordinated through one object.
For GuessTogether, the room limit is small enough that one Durable Object per room is a natural design.
Want to explore further?
Try the live demo or explore the source code on GitHub to see how the room state, WebSocket communication, scoring, and reconnection logic work together.




