Realtime Rendering Loops
Every technique in this cluster, WebSocket connections, presence awareness, optimistic updates, synchronization, latency hiding, ultimately has to produce pixels on a screen. The rendering loop is where all of it converges. It runs once per animation frame, reads the current state of the world including updates that arrived from the network, advances the simulation, and draws. Get it right and the interface feels fluid regardless of what the network is doing. Get it wrong and network events stutter the frame, delta-time explosions freeze the simulation, and the CPU runs at full speed even when the tab is invisible.
This is the capstone of the Interactive Web cluster. It connects the rendering mechanics from the browser rendering pipeline to the realtime data patterns built across the previous thirteen guides.
What you'll learn
The game loop pattern
The game loop is the oldest pattern in interactive software, and its structure is directly applicable to any web application that must update and render continuously.
Update, then draw, repeat
// Classic game loop structure adapted for the web
var lastTime = 0;
function loop(timestamp) {
var dt = (timestamp - lastTime) / 1000; // seconds
lastTime = timestamp;
// Cap dt to avoid spiral of death on tab resume
dt = Math.min(dt, 0.1);
update(dt); // advance simulation
render(); // draw current state
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
The loop does exactly two things every frame: update and render. The delta time, the elapsed seconds since the last frame, drives the update so the simulation advances proportionally to real time regardless of frame rate. Capping dt at around a hundred milliseconds prevents the simulation from making a huge jump when the tab was hidden and the loop was paused, which would otherwise launch objects to impossible positions.
requestAnimationFrame as the driver
requestAnimationFrame is the only correct way to drive a rendering loop in the browser. setInterval and setTimeout are wrong for this purpose, even at equivalent intervals.
Why rAF, not timers
requestAnimationFrame calls the callback exactly once before the browser paints the next frame, which means the loop and the display refresh are perfectly synchronised. This prevents tearing, where the screen shows half of one frame and half of the next. It also passes a high-resolution timestamp with sub-millisecond precision, making delta-time calculations accurate. Most importantly, requestAnimationFrame stops firing when the tab is hidden, automatically suspending the loop and sparing the CPU and battery. setInterval continues firing regardless. The browser rendering pipeline guide explained why this synchronisation matters for the full render pipeline: the loop must complete within the sixteen millisecond frame budget before the browser's own style, layout, and paint steps run.
Fixed update vs variable render
Tying simulation advancement directly to the display frame rate produces different results on a sixty-hertz monitor versus a one-hundred-twenty-hertz display. Separating the two rates solves this.
Accumulator-based fixed timestep
var FIXED_STEP = 1 / 60; // 60 Hz simulation
var accumulator = 0;
function loop(timestamp) {
var dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
// Accumulate real time and drain it in fixed steps
accumulator += dt;
while (accumulator >= FIXED_STEP) {
fixedUpdate(FIXED_STEP); // deterministic simulation step
accumulator -= FIXED_STEP;
}
// Alpha: how far into the current step we are
var alpha = accumulator / FIXED_STEP;
render(alpha); // interpolate between last and current state
requestAnimationFrame(loop);
}
The accumulator absorbs the variable real-time delta and drains it in fixed increments. The simulation advances by the same amount every step, making it deterministic and reproducible. The render pass receives an alpha value between zero and one representing how far through the current fixed step the display moment falls, and uses it to interpolate the drawn position between the previous and current simulation state. A sixty-hertz simulation running on a one-hundred-twenty-hertz display renders two frames per simulation step, each slightly ahead of the other, producing perfectly smooth output.
Integrating network state
Network messages arrive asynchronously and must not directly mutate simulation state from outside the loop. The correct integration point is at the start of each tick.
Network queue drained at tick start
var networkQueue = [];
// WebSocket handler: buffer only, never mutate state directly
ws.addEventListener('message', function (event) {
networkQueue.push(JSON.parse(event.data));
});
function fixedUpdate(dt) {
// 1. Drain network messages into simulation
while (networkQueue.length) {
applyNetworkMessage(networkQueue.shift(), simulationState);
}
// 2. Process local inputs
var inputs = inputQueue.splice(0);
inputs.forEach(function (input) { simulate(simulationState, input, dt); });
// 3. Advance physics / animation
advanceSimulation(simulationState, dt);
}
The WebSocket handler only pushes messages onto a queue; it never touches the simulation directly. At the start of each fixed update, the loop drains the queue and applies messages in order. This keeps the simulation update deterministic: within any given tick, messages are applied before the simulation advances, so the order of operations is always queue drain, input process, physics advance. Mixing asynchronous message handlers directly into a running simulation produces race conditions that are nearly impossible to debug.
Visibility-based pausing
A tab the user cannot see should not consume CPU. Beyond the automatic pausing requestAnimationFrame provides, explicit state management prevents problems on resume.
Pause on hidden, reset accumulator on restore
var loopId = null;
var paused = false;
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
paused = true;
// cancelAnimationFrame is optional since rAF already stops,
// but explicit cancellation prevents any pending callback from firing
if (loopId) { cancelAnimationFrame(loopId); loopId = null; }
} else {
paused = false;
// Reset lastTime so dt starts from zero, not from the hidden duration
lastTime = performance.now();
accumulator = 0;
loopId = requestAnimationFrame(loop);
}
});
Without the explicit reset, lastTime still holds the timestamp from when the tab was hidden. The first frame after restore would produce a huge dt, which the accumulator cap limits but does not eliminate gracefully. Resetting both lastTime and accumulator on restore gives the loop a clean start, as if the session had just begun. The network queue continues to fill while the tab is hidden; the loop drains it all on the first tick after restore, catching up to the current server state without a visible stutter.
Staying inside the frame budget
Every instruction in the loop competes for the same sixteen milliseconds. The capstone discipline is knowing where the time goes.
Profile, batch, and offload
The fixed update step should run in under two milliseconds to leave time for interpolation, rendering, and the browser's own style and layout work. If it does not, the first diagnostic is to profile with performance.mark and performance.measure around each phase, then look at the flame chart in Chrome DevTools. The most common causes of an overloaded update step are too many entities in the simulation, expensive collision or physics calculations, and DOM mutations triggered from inside the loop. The last is particularly destructive: any read of a layout-triggering property like offsetWidth inside the loop forces a synchronous layout, which alone can consume more than the entire budget. All DOM reads should happen before the loop's update phase and all DOM writes should happen in the render phase, separating them cleanly as the rendering lifecycle guide demanded. Heavy computation that does not need the DOM can be moved to a web worker, leaving the main thread free to stay inside budget for every frame.
Frequently Asked Questions
What is a realtime rendering loop?
A realtime rendering loop is a continuous cycle that runs on every animation frame, reads the latest application state including network updates, updates the simulation, and draws the frame. It is the bridge between asynchronous data arriving from a server and synchronous pixel output on the screen.
Why separate simulation updates from rendering?
Simulation updates at a fixed rate produce deterministic, reproducible results regardless of frame rate. Rendering at the variable display rate uses interpolation to produce smooth visuals even when the simulation step does not align with the frame. Separating them lets each run at its optimal rate.
How do I stop the loop when the tab is not visible?
The Page Visibility API fires a visibilitychange event when a tab is hidden or restored. Pause the loop on hidden and resume on visible. requestAnimationFrame already stops firing for hidden tabs in most browsers, but explicitly pausing and resetting lastTime and the accumulator prevents a large delta-time jump when the tab becomes visible again.
How do network messages fit into the rendering loop?
Network messages should not be applied directly inside the loop. Instead they are buffered in a queue as they arrive. At the start of each loop tick, the loop drains the queue and applies pending messages to the simulation state before running the update and render steps. This keeps the loop deterministic and the message handling off the critical render path.
Read next: the Interactive Web hub for the complete cluster, or revisit the browser rendering pipeline to see the browser-side mechanics this loop feeds into.
Want Backbone views that update cleanly on every frame?
The Backbone guide shows how event-driven views stay in sync with a continuously updating model.
Explore the Backbone Guide →