Building Real-Time Multiplayer Backends: State Synchronization, Conflict Resolution, and Latency Compensation in TypeScript
Most multiplayer backend guides stop at getting two clients connected. This covers what actually matters in production: networking model tradeoffs, delta-compressed state sync, conflict resolution under concurrent mutations, client-side prediction with server reconciliation, and how to scale from 10 to 10,000 concurrent players using WebSockets and Cloudflare Durable Objects.
Building a real-time multiplayer backend is a distributed systems problem with an unusually unforgiving feedback loop. In a database, a 200ms write is invisible. In a multiplayer game or collaborative application, it makes the experience feel broken. The gap between “it works in development” and “it works under real network conditions for real users” is wider here than in almost any other domain.
The problems are layered. First you choose a networking model. Then you decide how to represent and synchronize state. Then you handle the case where two clients modify the same state at the same time. Then you add latency compensation so the experience feels responsive despite 80-150ms round trips. Then you design the infrastructure to handle hundreds or thousands of simultaneous sessions without the whole system collapsing.
This covers all of it, with TypeScript code you can adapt directly.
Networking Model: Client-Server vs Peer-to-Peer vs Hybrid
The first decision shapes everything else. You have three models to choose from.
Client-server authoritative means one server owns the canonical game state. Clients send inputs (intents), not state changes. The server validates, applies, and broadcasts the result. This is the correct choice for most applications: it is the only model that reliably prevents cheating, and it gives you a single source of truth for reconciliation.
Peer-to-peer routes messages directly between clients. There is no authoritative server. Every peer agrees on state through a consensus mechanism. The appeal is lower latency (no server round trip) and lower infrastructure cost. The problems are significant: one peer becomes the de facto authority (host migration when they disconnect is painful), there is no reliable anti-cheat layer, and NAT traversal (STUN/TURN) adds complexity most teams underestimate. P2P is appropriate for games where latency is paramount and cheating is tolerable (local multiplayer, trusted networks).
Hybrid uses a server for authority on high-stakes state (player positions, scores, collision) and P2P for low-stakes, high-frequency state (cosmetic effects, cursor positions in collaborative tools). This is harder to build correctly but can reduce server cost and latency for the right workload.
For most production multiplayer applications, start with client-server authoritative. The rest of this article assumes that model.
Room Architecture with Cloudflare Durable Objects
The natural unit of a multiplayer session is a room: a named, isolated execution context that owns the state for one game session and the WebSocket connections for all players in it.
Cloudflare Durable Objects model this directly. One Durable Object instance per room, with the serialization guarantee meaning all mutations to room state are applied one at a time without distributed locking. Here is the base structure:
// src/objects/game-room.ts
interface PlayerState {
id: string;
x: number;
y: number;
health: number;
lastInputSeq: number;
}
interface GameState {
tick: number;
players: Record<string, PlayerState>;
updatedAt: number;
}
interface ClientInput {
seq: number; // monotonically increasing per client
dx: number; // movement delta
dy: number;
timestamp: number; // client-local timestamp
}
export class GameRoom implements DurableObject {
private state: DurableObjectState;
private gameState: GameState = { tick: 0, players: {}, updatedAt: 0 };
private tickInterval: ReturnType<typeof setInterval> | null = null;
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<GameState>('gameState');
if (stored) this.gameState = stored;
});
}
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader !== 'websocket') {
return new Response('Expected WebSocket upgrade', { status: 426 });
}
const url = new URL(request.url);
const playerId = url.searchParams.get('playerId');
if (!playerId) {
return new Response('Missing playerId', { status: 400 });
}
const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
this.state.acceptWebSocket(server);
server.serializeAttachment({ playerId });
// Register player in game state
if (!this.gameState.players[playerId]) {
this.gameState.players[playerId] = {
id: playerId,
x: Math.random() * 800,
y: Math.random() * 600,
health: 100,
lastInputSeq: 0,
};
}
// Send full state snapshot to new client
server.send(JSON.stringify({
type: 'init',
state: this.gameState,
yourId: playerId,
}));
// Start tick loop if not already running
if (!this.tickInterval) {
this.startTickLoop();
}
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
const { playerId } = ws.deserializeAttachment();
const message = JSON.parse(raw as string);
if (message.type === 'input') {
this.applyInput(playerId, message.input as ClientInput);
}
}
async webSocketClose(ws: WebSocket): Promise<void> {
const { playerId } = ws.deserializeAttachment();
delete this.gameState.players[playerId];
this.broadcast({ type: 'playerLeft', playerId });
const activePlayers = Object.keys(this.gameState.players).length;
if (activePlayers === 0 && this.tickInterval) {
clearInterval(this.tickInterval);
this.tickInterval = null;
}
}
private applyInput(playerId: string, input: ClientInput): void {
const player = this.gameState.players[playerId];
if (!player) return;
// Only apply inputs in sequence order
if (input.seq <= player.lastInputSeq) return;
const SPEED = 4;
player.x = Math.max(0, Math.min(800, player.x + input.dx * SPEED));
player.y = Math.max(0, Math.min(600, player.y + input.dy * SPEED));
player.lastInputSeq = input.seq;
}
private startTickLoop(): void {
const TICK_RATE_HZ = 20; // 20 ticks per second
this.tickInterval = setInterval(() => {
this.tick();
}, 1000 / TICK_RATE_HZ);
}
private tick(): void {
this.gameState.tick++;
this.gameState.updatedAt = Date.now();
// Broadcast delta, not full state, every tick
this.broadcastDelta();
}
private broadcast(message: unknown): void {
const payload = JSON.stringify(message);
for (const ws of this.state.getWebSockets()) {
try {
ws.send(payload);
} catch {
// connection already closed
}
}
}
private broadcastDelta(): void {
// Covered in the next section
}
}
The serialization guarantee eliminates race conditions on applyInput: two inputs arriving simultaneously queue up and execute in order. No locks, no compare-and-swap.
State Synchronization: Snapshots, Deltas, and Interest Management
Sending full state every tick does not scale. At 20 ticks/second with 50 players, each carrying 200 bytes of state, that is 20 * 50 * 200 = 200,000 bytes per second per client. At 50 clients, 10 MB/s outbound from a single room. This is unsustainable.
The alternative is delta compression: send only what changed since the last acknowledged tick.
// src/objects/game-room.ts (continued)
interface PlayerDelta {
id: string;
x?: number;
y?: number;
health?: number;
}
interface StateDelta {
tick: number;
baseTick: number; // the tick this delta is relative to
players: PlayerDelta[];
removed: string[]; // player IDs that left
}
class DeltaCompressor {
private snapshots = new Map<number, GameState>();
private readonly maxSnapshots = 60; // ~3 seconds at 20 Hz
record(state: GameState): void {
this.snapshots.set(state.tick, structuredClone(state));
// Evict old snapshots
if (this.snapshots.size > this.maxSnapshots) {
const oldest = Math.min(...this.snapshots.keys());
this.snapshots.delete(oldest);
}
}
delta(fromTick: number, toState: GameState): StateDelta {
const from = this.snapshots.get(fromTick);
if (!from) {
// Client is too far behind. Send a full snapshot instead.
return this.fullSnapshot(toState);
}
const players: PlayerDelta[] = [];
const removed: string[] = [];
for (const [id, current] of Object.entries(toState.players)) {
const prev = from.players[id];
if (!prev) {
// New player: include full state
players.push({ id, x: current.x, y: current.y, health: current.health });
continue;
}
const delta: PlayerDelta = { id };
if (Math.abs(current.x - prev.x) > 0.01) delta.x = current.x;
if (Math.abs(current.y - prev.y) > 0.01) delta.y = current.y;
if (current.health !== prev.health) delta.health = current.health;
if (Object.keys(delta).length > 1) {
players.push(delta);
}
}
for (const id of Object.keys(from.players)) {
if (!toState.players[id]) removed.push(id);
}
return {
tick: toState.tick,
baseTick: fromTick,
players,
removed,
};
}
private fullSnapshot(state: GameState): StateDelta {
return {
tick: state.tick,
baseTick: -1, // signals full snapshot to client
players: Object.values(state.players).map(p => ({
id: p.id, x: p.x, y: p.y, health: p.health,
})),
removed: [],
};
}
}
Interest management takes this further. In a large world, each client only needs state for entities within their observable range. The server partitions the world into cells (spatial hashing) and only includes entities in the same or adjacent cells in the delta for each client.
For most games under 100 players per room, delta compression alone gets you to a manageable bandwidth budget. Interest management becomes necessary at larger scale or larger world sizes.
Conflict Resolution: Concurrent State Mutations
When multiple clients modify shared state simultaneously, you need a deterministic resolution strategy. The wrong approach is to let the last write win: two players grabbing the same item, both clients see themselves win, then one gets corrected by the server. This causes jarring snaps.
The correct approach for most cases is server authority with client reconciliation. The server applies inputs in arrival order, which is deterministic and consistent. Clients predict the outcome locally and correct when the authoritative result arrives. Corrections are interpolated to avoid snapping.
For truly concurrent modifications to shared resources (picking up an item, capturing a flag), the server validates before applying:
// src/objects/game-room.ts
interface ItemState {
id: string;
x: number;
y: number;
heldBy: string | null;
}
interface PickupInput {
seq: number;
itemId: string;
playerId: string;
}
function applyPickup(
items: Record<string, ItemState>,
players: Record<string, PlayerState>,
input: PickupInput
): { success: boolean; reason?: string } {
const item = items[input.itemId];
if (!item) return { success: false, reason: 'item_not_found' };
if (item.heldBy !== null) return { success: false, reason: 'already_held' };
const player = players[input.playerId];
if (!player) return { success: false, reason: 'player_not_found' };
// Validate proximity: client cannot pick up an item 500 units away
const dist = Math.hypot(player.x - item.x, player.y - item.y);
if (dist > 50) return { success: false, reason: 'out_of_range' };
item.heldBy = input.playerId;
return { success: true };
}
The critical property: applyPickup is a pure function. The same inputs always produce the same output. This makes the server authoritative and the outcome testable.
When a pickup fails, the server sends the rejected client a correction:
const result = applyPickup(gameState.items, gameState.players, input);
if (!result.success) {
ws.send(JSON.stringify({
type: 'inputRejected',
seq: input.seq,
reason: result.reason,
}));
} else {
broadcast({ type: 'itemPickedUp', itemId: input.itemId, by: input.playerId });
}
The client that predicted success now receives inputRejected and rolls back its local prediction. The rollback should be fast and smooth: animate the item back to its original position over 100-150ms rather than snapping it.
Latency Compensation: Making It Feel Instant
At 100ms round-trip latency, a naive client-server system feels sluggish. You press a key, wait 50ms for the input to reach the server, wait 50ms for the response, then see the result. That 100ms delay is perceptible. The solution is a set of techniques that hide the round trip from the player.
Client-Side Prediction
The client applies its own inputs immediately, without waiting for server confirmation. The player sees instant movement. In parallel, the input is sent to the server.
// Client-side (browser)
interface PendingInput {
seq: number;
dx: number;
dy: number;
timestamp: number;
predictedState: { x: number; y: number }; // what we predicted
}
class GameClient {
private localState = { x: 400, y: 300 };
private pendingInputs: PendingInput[] = [];
private inputSeq = 0;
private socket: WebSocket;
sendInput(dx: number, dy: number): void {
const seq = ++this.inputSeq;
const timestamp = Date.now();
// Apply immediately (prediction)
const SPEED = 4;
this.localState.x = Math.max(0, Math.min(800, this.localState.x + dx * SPEED));
this.localState.y = Math.max(0, Math.min(600, this.localState.y + dy * SPEED));
const pendingInput: PendingInput = {
seq,
dx,
dy,
timestamp,
predictedState: { ...this.localState },
};
this.pendingInputs.push(pendingInput);
this.socket.send(JSON.stringify({
type: 'input',
input: { seq, dx, dy, timestamp },
}));
}
}
Server Reconciliation
When the server’s authoritative state arrives, the client reconciles: it discards pending inputs that the server has acknowledged, then replays the remaining unacknowledged inputs on top of the server state.
// Client-side (continued)
onServerUpdate(serverState: PlayerState): void {
// Remove inputs the server has processed
this.pendingInputs = this.pendingInputs.filter(
input => input.seq > serverState.lastInputSeq
);
// Start from the authoritative server position
let x = serverState.x;
let y = serverState.y;
const SPEED = 4;
// Replay unacknowledged inputs on top
for (const input of this.pendingInputs) {
x = Math.max(0, Math.min(800, x + input.dx * SPEED));
y = Math.max(0, Math.min(600, y + input.dy * SPEED));
}
// If the reconciled position differs from our prediction, correct it
const drift = Math.hypot(x - this.localState.x, y - this.localState.y);
if (drift < 2) {
// Small drift: snap silently (sub-pixel correction)
this.localState = { x, y };
} else {
// Larger drift: interpolate to avoid visual snap
this.animateTo({ x, y }, 100); // 100ms correction window
}
}
The threshold for silent vs animated correction is tunable. A threshold too low causes constant micro-animations. A threshold too high lets drift accumulate. Most games use 1-5 pixels for silent correction and animate anything larger.
Interpolation for Other Players
Your own movement benefits from prediction. But other players’ positions arrive at discrete tick intervals (50ms at 20 Hz). Rendering them at their raw received positions causes visual stutter.
The solution is to render other players at a delayed, interpolated position. Buffer their last two positions and interpolate between them:
// Client-side (continued)
interface RemotePlayer {
id: string;
positionBuffer: Array<{ x: number; y: number; timestamp: number }>;
}
function interpolatedPosition(
player: RemotePlayer,
renderTimestamp: number
): { x: number; y: number } {
const INTERPOLATION_DELAY_MS = 100; // render 100ms in the past
const renderTime = renderTimestamp - INTERPOLATION_DELAY_MS;
const buffer = player.positionBuffer;
// Find the two snapshots that bracket renderTime
for (let i = buffer.length - 1; i >= 1; i--) {
const newer = buffer[i];
const older = buffer[i - 1];
if (older.timestamp <= renderTime && renderTime <= newer.timestamp) {
const t = (renderTime - older.timestamp) / (newer.timestamp - older.timestamp);
return {
x: older.x + (newer.x - older.x) * t,
y: older.y + (newer.y - older.y) * t,
};
}
}
// Fallback: extrapolate from the latest position
const latest = buffer[buffer.length - 1];
return { x: latest.x, y: latest.y };
}
The 100ms delay means you trade recency for smoothness. Other players are rendered slightly in the past, but their movement is visually continuous. This is the right tradeoff for everything except reaction-time-critical competitive games.
Tradeoffs
| Technique | Benefit | Cost | When to skip |
|---|---|---|---|
| Client-side prediction | Instant local response | Reconciliation complexity, visible corrections | Turn-based games, low latency requirements |
| Server reconciliation | Correctness with prediction | Replay buffer, rollback logic | When prediction is not used |
| Interpolation for others | Smooth remote movement | 100ms added rendering delay | Local multiplayer, LAN |
| Delta compression | Reduced bandwidth | Compression logic, snapshot ring buffer | Small rooms (under 20 players) |
| Interest management | Scalable large worlds | Spatial indexing, cell boundary logic | Small maps, all entities always relevant |
| Authoritative server | Cheat prevention, consistency | RTT latency, server cost | Cooperative games with trusted clients |
Infrastructure: Scaling from 10 to 10,000 Players
A single room running on one Durable Object handles its workload well. The scaling challenge is across rooms, not within them.
Connection Routing
Players connect to a Cloudflare Worker that routes them to the correct Durable Object by room ID:
// src/workers/router.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get('roomId');
if (!roomId) {
return new Response('Missing roomId', { status: 400 });
}
// Authenticate before routing
const authHeader = request.headers.get('Authorization');
const playerId = await verifyToken(authHeader, env);
if (!playerId) {
return new Response('Unauthorized', { status: 401 });
}
// Route to the room's Durable Object
const id = env.GAME_ROOM.idFromName(roomId);
const room = env.GAME_ROOM.get(id);
// Forward with playerId as query param for the object to read
const roomUrl = new URL(request.url);
roomUrl.searchParams.set('playerId', playerId);
return room.fetch(new Request(roomUrl, request));
},
};
The room ID is stable, so the same room name always maps to the same Durable Object instance. No registry needed.
Matchmaking
Matchmaking is a separate concern from room management. A simple matchmaking service tracks open rooms and assigns players:
// src/objects/matchmaker.ts
interface RoomSlot {
roomId: string;
playerCount: number;
maxPlayers: number;
createdAt: number;
}
export class Matchmaker implements DurableObject {
private state: DurableObjectState;
private openRooms: Map<string, RoomSlot> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<RoomSlot[]>('openRooms');
if (stored) this.openRooms = new Map(stored.map(r => [r.roomId, r]));
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/join') {
return this.handleJoin(request);
}
if (url.pathname === '/update') {
return this.handleUpdate(request);
}
return new Response('Not found', { status: 404 });
}
private async handleJoin(request: Request): Promise<Response> {
const MAX_PLAYERS = 10;
// Find a room with space
for (const [roomId, slot] of this.openRooms) {
if (slot.playerCount < slot.maxPlayers) {
slot.playerCount++;
await this.persist();
return Response.json({ roomId });
}
}
// No open room: create a new one
const roomId = crypto.randomUUID();
this.openRooms.set(roomId, {
roomId,
playerCount: 1,
maxPlayers: MAX_PLAYERS,
createdAt: Date.now(),
});
await this.persist();
return Response.json({ roomId });
}
private async handleUpdate(request: Request): Promise<Response> {
const { roomId, playerCount } = await request.json<{
roomId: string;
playerCount: number;
}>();
if (playerCount === 0) {
this.openRooms.delete(roomId);
} else {
const slot = this.openRooms.get(roomId);
if (slot) slot.playerCount = playerCount;
}
await this.persist();
return Response.json({ ok: true });
}
private async persist(): Promise<void> {
await this.state.storage.put('openRooms', [...this.openRooms.values()]);
}
}
The Matchmaker itself is a Durable Object. This gives you serialized matchmaking operations without managing locks. The single-instance constraint is fine: matchmaking latency is not in the critical rendering path.
Production Considerations
Tick rate is a budget, not a target. At 20 ticks/second, each tick has a 50ms budget. If game logic, state delta computation, and broadcast all take more than 50ms, your tick loop falls behind. Profile your tick handler under load before you ship.
Durable Objects have a 1,000-connection limit per instance. A room capped at 10-100 players is well within this. If you need rooms with more players, shard: multiple Durable Object instances per logical room, with a coordinator object aggregating state for cross-shard broadcasts.
Persist selectively. Writing gameState to Durable Object storage every tick is wasteful. Write on significant state changes (player joined, player died, game ended) and recover from the latest checkpoint on cold start. For active game sessions, in-memory state is the source of truth.
Monitor per-room metrics. Instrument tick duration, player count, message throughput, and reconnection rate per room. An anomalous room (one player’s broken client flooding inputs) should be detectable before it degrades everyone else’s experience.
Handle clock skew. Client timestamps are not reliable. The timestamp field in inputs is useful for ordering inputs from the same client, but do not trust it for wall-clock comparisons across clients. The server clock is authoritative.
Reconnection state. When a client reconnects, send a full state snapshot and the last acknowledged input sequence. The client resumes prediction from there. A reconnection after a 5-second dropout should feel like a load screen, not a snap to a random position.
The Architecture in One View
Player (browser)
|
|-- WebSocket (wss://) --> Cloudflare Worker (router.ts)
|
|-- Auth check
|-- idFromName(roomId) --> Durable Object (GameRoom)
|
|-- applyInput()
|-- tick() @ 20Hz
|-- delta compress
|-- broadcast to all WebSockets
The Worker is stateless. The Durable Object owns all room state and all connections. The player never talks to more than one server. There is no Redis, no session store, no external pub/sub layer. The platform’s actor model handles the coordination guarantee.
Closing
The hardest part of a multiplayer backend is not the code. It is knowing which technique to apply and when. Client-side prediction adds real complexity and should only be added when latency is genuinely perceptible and the game design supports rollback. Delta compression is always worth the effort once you have more than a handful of players in a room. Interpolation for remote players is cheap and eliminates the most common visual artifact.
Start with a single room, a 20Hz tick, and full state broadcasts. Measure the bandwidth. Add delta compression when the numbers demand it. Add prediction when players complain about feel. The techniques stack well but each one has a maintenance cost. Ship the simpler thing first.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.