Latency and Reconciliation
Every realtime technique in this cluster assumes that messages travel with some delay. Prediction hides that delay. Interpolation smooths over it. Reconciliation corrects for it when prediction was wrong. But none of those techniques work well unless you first understand exactly how much latency you are dealing with, where it comes from, and what its variance looks like. Measuring, hiding, and recovering from latency are three distinct engineering tasks, and this guide covers all three.
This guide completes the multiplayer trilogy alongside multiplayer state synchronization and is the final article before the capstone realtime rendering loops.
What you'll learn
Measuring round-trip time and clock offset
You cannot tune what you cannot measure. The two numbers that matter most are round-trip time and the offset between the client and server clocks.
Ping-pong with timestamps
var rttSamples = [];
var clockOffset = 0;
function measureLatency(ws) {
var t0 = Date.now();
ws.send(JSON.stringify({ type: 'ping', clientTime: t0 }));
}
ws.addEventListener('message', function (event) {
var msg = JSON.parse(event.data);
if (msg.type === 'pong') {
var t2 = Date.now();
var rtt = t2 - msg.clientTime;
var oneWay = rtt / 2;
// Server sent its time at (t2 - oneWay) according to client clock
clockOffset = msg.serverTime - (t2 - oneWay);
rttSamples.push(rtt);
if (rttSamples.length > 10) rttSamples.shift();
}
});
function avgRtt() {
return rttSamples.reduce(function (s, n) { return s + n; }, 0) / rttSamples.length;
}
The server echoes the client's timestamp and adds its own. Half the round trip gives an approximation of one-way latency. The clock offset tells the client how to interpret server timestamps: add it to convert a server timestamp to the equivalent client-clock value. Run the measurement every thirty seconds and keep a rolling average to track whether conditions are improving or degrading during a session.
Hiding latency with prediction
The best latency is the kind the user never perceives. Prediction, introduced in the multiplayer sync guide, is the primary tool.
Tuning prediction to measured RTT
// Adapt prediction horizon to current latency
function shouldPredict(action) {
var rtt = avgRtt();
// Below 50ms: prediction noise may be worse than the wait
if (rtt < 50) return false;
// Above 300ms: prediction errors become visually large; show pending indicator
if (rtt > 300) return false;
return true;
}
// Prediction depth: how far ahead to simulate
function predictionMs() {
return Math.min(avgRtt() * 0.6, 150); // cap at 150ms
}
Prediction is not always beneficial. Below about fifty milliseconds of round-trip time, the server response arrives so quickly that the predicted state and the authoritative state are virtually identical; prediction adds complexity with no perceptible benefit. Above about three hundred milliseconds, prediction errors accumulate faster than the server can correct them, producing visible jitter on reconciliation. In that zone, showing a pending indicator while waiting for the server is often a better experience than a correction snap. Tuning the prediction horizon to the measured RTT keeps prediction in the range where it helps.
Smooth state correction
When the authoritative server state differs from the predicted state, applying the correction instantly creates a jarring visual snap.
Interpolating toward the authoritative state
var correctionState = null;
var correctionStart = 0;
var CORRECTION_MS = 80; // blend over 80ms
function onServerReconcile(authoritative) {
var predicted = localState;
var diff = distance(predicted, authoritative);
if (diff < 2) {
// Negligible error: snap silently
localState = authoritative;
return;
}
// Large error: blend over CORRECTION_MS
correctionState = { from: predicted, to: authoritative };
correctionStart = performance.now();
}
function renderFrame(now) {
if (correctionState) {
var t = (now - correctionStart) / CORRECTION_MS;
if (t >= 1) {
localState = correctionState.to;
correctionState = null;
} else {
localState = lerp(correctionState.from, correctionState.to, easeOut(t));
}
}
draw(localState);
requestAnimationFrame(renderFrame);
}
Small corrections under a threshold snap silently because a one-pixel adjustment is imperceptible. Larger corrections blend over a short window, typically fifty to one hundred milliseconds, using a smooth easing function. The result looks like a slight natural correction rather than a teleport. The threshold and blend duration are tunable; faster-moving content needs shorter corrections to avoid trailing artefacts, while slower-moving content can afford longer blends.
Jitter buffers
Even on a connection with a reasonable average latency, individual messages arrive at irregular intervals. Jitter is the variance in that arrival time, and it causes updates to bunch and then gap in ways that prediction alone cannot smooth.
A fixed-delay playback buffer
var BUFFER_MS = 100;
var messageQueue = [];
ws.addEventListener('message', function (event) {
var msg = JSON.parse(event.data);
msg.deliverAt = performance.now() + BUFFER_MS;
messageQueue.push(msg);
messageQueue.sort(function (a, b) { return a.deliverAt - b.deliverAt; });
});
function processMessages(now) {
while (messageQueue.length && messageQueue[0].deliverAt <= now) {
applyMessage(messageQueue.shift());
}
requestAnimationFrame(processMessages);
}
The buffer holds every incoming message for a fixed delay before processing it. Messages that arrive out of order during that window get sorted before delivery, providing a smooth, ordered stream. The cost is a fixed additional delay equal to the buffer size. Choosing the buffer size is a trade-off: larger buffers handle more jitter but add more delay. A good starting point is one to two times the standard deviation of the measured RTT; fifty to one hundred milliseconds covers most home and mobile networks.
Reconnection and recovery
Connections drop. A well-designed client recovers gracefully with no user intervention.
Backoff, snapshot request, and resume
var reconnectAttempt = 0;
function connect() {
var ws = new WebSocket('wss://example.com/realtime');
ws.addEventListener('open', function () {
reconnectAttempt = 0;
// Request catch-up from last known sequence
ws.send(JSON.stringify({ type: 'resume', lastSeq: localState.seq }));
});
ws.addEventListener('close', function (event) {
if (event.code === 1000) return;
var delay = Math.min(200 * Math.pow(2, reconnectAttempt), 30000);
delay += Math.random() * 500; // jitter
reconnectAttempt++;
setTimeout(connect, delay);
});
ws.addEventListener('message', function (event) {
var msg = JSON.parse(event.data);
if (msg.type === 'snapshot') {
localState = msg.state;
} else {
applyMessage(msg);
}
});
}
connect();
Exponential backoff with jitter prevents all clients from reconnecting at exactly the same moment after a server restart, exactly as the WebSocket guide described. On reconnect, the client requests a catch-up from its last known sequence number. If the server can replay the gap, the client catches up without a full reset. If the gap is too large, the server sends a snapshot and the client resets cleanly. The user sees nothing; the state is simply current again.
Monitoring latency in production
Lab measurements give you a baseline. Production measurements tell you what users actually experience.
Emit latency metrics from the client
Emit the rolling average RTT and the clock offset to your analytics pipeline periodically, alongside the user's connection type if the Network Information API is available. Track the ninety-fifth percentile, not just the average, because the tail of the latency distribution is where users feel pain. If the p95 RTT climbs above two hundred milliseconds for a significant fraction of users, prediction configuration, jitter buffer size, and server region selection all become worth revisiting. The monitoring closes the loop between the engineering patterns in this cluster and the real-world conditions they must operate under.
Frequently Asked Questions
How do you measure round-trip latency over WebSocket?
Send a ping message tagged with the client timestamp and note the server timestamp when it responds. Half the round-trip time is an approximation of one-way latency. Averaging several measurements reduces noise. The difference between the client and server clocks gives the clock offset needed to interpret server timestamps correctly.
Why does reconciliation cause visual snapping?
When the authoritative server state differs from the client's predicted state, applying the correction instantly teleports objects to their correct position. The fix is to interpolate toward the authoritative state over a short window, typically one to three frames, so the correction looks like a small smooth adjustment rather than a jump.
What is a jitter buffer?
A jitter buffer delays playback of received messages by a fixed window, typically 50 to 150 milliseconds, to absorb variability in arrival times. Messages that arrive out of order during the window are sorted before delivery, providing a smooth, ordered stream at the cost of a small fixed delay.
How should a realtime client handle reconnection?
On reconnect, the client should request a full state snapshot or catch-up replay from its last known sequence number. It should then resume live updates. The reconnection attempt should use exponential backoff with jitter to avoid all clients reconnecting simultaneously after a server restart.
Read next: the Interactive Web hub, or continue to the final article realtime rendering loops to see how these techniques connect to the browser's frame pipeline.
Want resilient Backbone models under variable latency?
The Backbone guide shows event-driven model patterns that handle live data updates gracefully.
Explore the Backbone Guide →