Client-side prediction and rollback netcode in the browser

The first time I wired a browser game up to a real server over a real connection, it went from feeling crisp to feeling like typing through treacle. Nothing was broken — the round trip was just visible. You press a key, the packet flies to the server, the server simulates, the state flies back, and only then does your character move. At 100 ms round trip that's a tenth of a second of dead air on every single input, and players notice it immediately.

The fix is a pair of techniques that get muddled together constantly: client-side prediction (act now, ask later) and rollback (when "later" disagrees with you, rewind and redo). This is the practical version — the buffers, the tick loop and the corrections — written the way you'd actually build it in JavaScript.

Step 0: a fixed tick, not a frame

None of this works if your simulation is driven by requestAnimationFrame deltas. Rollback means re-running past inputs and getting the same answer, and variable timesteps make that impossible. So separate the two loops: render as fast as the display wants, simulate in fixed steps.

const TICK = 1000 / 60; // 16.667 ms
let acc = 0, last = performance.now();
function frame(now) {
  acc += now - last; last = now;
  while (acc >= TICK) { step(sampleInput()); acc -= TICK; tick++; }
  render(acc / TICK); // interpolate the leftover
  requestAnimationFrame(frame);
}

Every input now belongs to a numbered tick, and both machines agree what a tick means. That number is the currency of everything below. One detail worth stealing: capture key state in the DOM event handler and read the accumulated flags in sampleInput(), rather than polling the keyboard inside the render callback — otherwise a tap that starts and ends between two frames disappears entirely.

Step 1: predict locally, keep the receipt

Prediction is almost embarrassingly simple. Instead of sending the input and waiting, you send it and apply it straight away with exactly the same movement code the server runs. But you must keep a copy of every input you've sent and not yet had confirmed:

const pending = []; // inputs sent, not yet acknowledged
function step(input) {
  input.tick = tick;
  net.send(input);
  pending.push(input);
  applyInput(localPlayer, input, TICK); // shared with the server
}

applyInput living in one file used by both client and server is not a nicety, it's the whole trick. Two implementations of "how fast does the player walk" drift apart, and drift becomes constant rubber-banding.

Step 2: reconcile against the server

The server is the authority — it validates inputs, applies them, and broadcasts snapshots that include the last tick it processed for you. When a snapshot lands, the client does three things in order:

  1. Adopt the authoritative state for your player from the snapshot.
  2. Drop every pending input with tick <= snapshot.ackTick — the server has already accounted for those.
  3. Re-simulate the inputs still in the buffer, in order, on top of the new state.

function onSnapshot(s) {
  localPlayer.state = s.you; // rewind
  while (pending.length && pending[0].tick <= s.ackTick) pending.shift();
  for (const i of pending) applyInput(localPlayer, i, TICK); // replay
}

That loop is rollback. Most of the time the replay lands on the same position you were already showing, and the player sees nothing at all — which is the point. When it doesn't (you were shoved, a wall stopped you, a shot was rejected) your view snaps onto the truth in a single tick instead of drifting for seconds.

client timeline t10 t11 t12 t13 t14 predicted locally snapshot: ack t10 → adopt server state, drop t10 replay t11' t12' t13' t14' corrected now one snapshot = rewind to the last acknowledged tick, then re-simulate the unacknowledged ones
Prediction runs ahead of the server; reconciliation rewinds to the ack and replays the buffer.

Step 3: other players get interpolation, not prediction

You can't predict someone else's intentions, so don't try. Buffer remote entity snapshots and render them slightly in the past — typically one to two snapshot intervals behind, so you always have a "before" and an "after" to interpolate between. The cost is that other players are rendered ~100 ms stale; the benefit is they move smoothly instead of stuttering between packets. If you extrapolate them forward instead, you buy responsiveness and pay for it with characters that overshoot walls and jerk backwards.

This asymmetry is the standard architecture: prediction for you, interpolation for them, lag compensation on the server for anything that must be judged — hit detection especially, where the server rewinds other players to where the shooter actually saw them.

Step 4: hide the corrections

Raw reconciliation is correct and ugly. If you assign the corrected position straight to what you draw, every mispredicted tick becomes a visible twitch. The standard remedy is a visual error offset:

  • Before replaying, remember the position you last rendered.
  • After replaying, store error = oldRendered − corrected.
  • Each frame, render corrected + error and decay error toward zero over about 100–150 ms.

The simulation stays honest; only the camera lies, briefly. Add one escape hatch: if the error exceeds some threshold — a respawn, a teleport, a tab that was backgrounded for two seconds — snap instead, because smoothing a huge error just looks like a bug.

Server-authoritative rollback vs peer-to-peer rollback

The word "rollback" comes from the fighting-game world, where GGPO popularised it back in 2009, and that version is stricter than what I've described. In peer-to-peer rollback there is no authority: every peer runs the identical deterministic simulation, predicts the remote player's input (usually "same as last frame"), and rolls back the whole world when the real input arrives. It gives genuinely offline-feeling input latency, and it demands total determinism plus fast save/load of full game state. Browser libraries exist for this over WebRTC data channels, with periodic state hashing to detect desyncs.

Server-authoritative prediction is the more forgiving cousin, and the right default for anything where cheating matters. Small floating-point differences don't compound, because the server's snapshot overwrites your state on every packet. You're rolling back only your own player, using the server as the checkpoint.

Rule of thumb: if the game must be cheat-resistant, go server-authoritative with prediction and reconciliation. If two players need frame-perfect fairness and trust each other, go deterministic peer-to-peer rollback.

What actually bites you

  • Two copies of the movement code. The single most common cause of permanent rubber-banding. Share the module.
  • Unbounded pending buffers. If the server stops acknowledging, the replay loop grows every tick until the frame rate dies. Cap it and force a hard resync.
  • Non-determinism sneaking in. Math.random() in movement, iteration over an unordered map, or Date.now() instead of the tick counter — all reproduce differently on replay.
  • Correcting from stale snapshots. Snapshots can arrive out of order over UDP-ish transports; ignore any whose ackTick is older than the last one you applied.
  • Sending a packet per keypress. Batch the last few unacknowledged inputs into each outgoing message; they're tiny, and redundancy costs less than a lost input.

The short version

Latency can't be removed, only hidden — and it's hidden by letting the client be optimistic while keeping a receipt for everything it assumed. A fixed tick makes the past reproducible, an input buffer makes it replayable, the server's acknowledgement tells you how far back to rewind, and a decaying error offset keeps the repair invisible. Everything after that is tuning: how many ticks you buffer, how long you smooth for, and how much you trust the client. Get the four pieces in the right order and a 120 ms connection can feel local.

Related
→ WebSocket vs SSE vs WebTransport: picking the right real-time channel → Building real-time multiplayer with Supabase Realtime → Hitscan vs projectile: shooting mechanics in a browser FPS → Portfolio & projects
What's the difference between client-side prediction and rollback netcode?

Prediction means the client simulates its own inputs immediately instead of waiting for the server, so movement feels instant. Rollback is the correction half: when authoritative data arrives and disagrees, the client restores the last known-good state and re-simulates every buffered input up to the present frame. Prediction hides latency; rollback repairs mispredictions.

Do I need a fully deterministic game loop?

For peer-to-peer rollback, yes — every peer runs the same simulation from the same inputs, so any non-determinism desyncs. Server-authoritative games need far less, because the server snapshot overwrites the client instead of letting drift accumulate. A fixed timestep is still essential so a replay reproduces the original result.

How do you stop corrections from making players teleport?

Don't snap. Keep the difference between the previously rendered position and the corrected one as a visual error offset, and decay it to zero over roughly 100–150 ms while the simulation uses the corrected value. Only snap when the error is so large that smoothing would look wrong — respawns, teleports, long stalls.

Is rollback overkill for a casual browser game?

Often, yes. Full rollback snapshots the whole world every tick and earns its cost in twitch games. For slower ones, a fixed input delay or snapshotting only the local player's state gives most of the responsiveness for a fraction of the CPU and memory.