Decoupling Simulation from Render with a Fixed Timestep
Your force-directed graph settles into different layouts on a 60Hz laptop and a 144Hz monitor, and a single hitched frame sends nodes jittering — because every position update multiplies velocity by a variable frame delta.
Tying simulation to the render delta means the physics inherits every wobble in frame timing. This page is a companion within frame rate stabilization techniques: the cure is to advance the simulation in fixed, identical increments regardless of how often the screen refreshes, and render whatever the latest state happens to be. The two clocks run independently — the simulation ticks at a constant rate for determinism, the renderer draws at the display’s rate for smoothness — joined only by an accumulator and an interpolation factor.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: simulation advances by the variable render delta.
let last = performance.now();
function frame(now: number): void {
const delta: number = now - last; // varies: 16.7ms, 8.3ms, 40ms on a hitch
last = now;
for (const n of nodes) {
n.vx += n.ax * delta; // non-deterministic; diverges on long frames
n.x += n.vx * delta; // 144Hz and 60Hz settle differently
}
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// ✅ FIXED: fixed-step accumulator decouples simulation from render.
const STEP = 1000 / 60; // fixed 16.67ms simulation tick
const MAX_STEPS = 5; // frame-skip cap: no spiral of death
let accumulator = 0;
let last = performance.now();
function frame(now: number): void {
// PERF: performance.now() is monotonic and sub-millisecond; Date.now() is not.
let delta: number = now - last;
last = now;
if (delta > 250) delta = 250; // clamp a huge stall (tab was backgrounded)
accumulator += delta;
let steps = 0;
while (accumulator >= STEP && steps < MAX_STEPS) {
savePreviousState(); // snapshot for interpolation
simulate(STEP); // identical increment every tick
accumulator -= STEP;
steps++;
}
if (steps === MAX_STEPS) accumulator = 0; // shed backlog instead of spiralling
const alpha: number = accumulator / STEP; // leftover fraction in [0, 1)
render(alpha); // A11Y: honour prefers-reduced-motion in render
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
The accumulator is the whole idea. Each frame adds the real elapsed time to it, then the loop drains it in exact STEP chunks, running simulate(STEP) zero, one, or several times so the simulation always advances by identical increments no matter the frame rate. Because every tick is the same size, the physics is deterministic: the same starting state produces the same trajectory on any display. The MAX_STEPS cap is the safety valve — without it, one slow frame queues a pile of catch-up steps, each of which makes the next frame slower, a positive-feedback “spiral of death”. Capping the steps and discarding the leftover trades a moment of slow-motion for a loop that always recovers.
Step-by-step fix
function lerp(prev: number, curr: number, alpha: number): number {
return prev + (curr - prev) * alpha; // sub-frame position for smooth render
}
Verification
// Feeding identical total time in different-sized frames must reach the same state.
function run(frameDeltas: readonly number[]): number {
reset();
let acc = 0;
for (const d of frameDeltas) { acc += d; while (acc >= STEP) { simulate(STEP); acc -= STEP; } }
return nodes[0].x;
}
const smooth = run([16.7, 16.7, 16.7, 16.7, 16.7, 16.7]); // ~100ms in 60Hz frames
const jittery = run([8, 40, 5, 30, 8.3, 8.4]); // ~100ms in irregular frames
console.assert(Math.abs(smooth - jittery) < 1e-6, "simulation not frame-rate independent");
The determinism assertion is the definitive check: equal total elapsed time must yield an identical simulation state regardless of how it was chopped into frames. In DevTools, throttle the CPU to 6× and confirm the layout still converges to the same arrangement, only more slowly, rather than diverging or exploding.
Why interpolation removes the last of the stutter
Fixing the timestep makes the simulation deterministic, but it introduces a subtle visual artefact: the render rate and the tick rate are no longer aligned, so on any given frame the simulation is usually part-way between two ticks. Snapping the render to the latest completed tick makes objects appear to move in the tick’s rhythm rather than the display’s, which reads as a faint stutter especially at high refresh rates. Interpolation fixes it by drawing each object at lerp(previous, current, alpha), where alpha is the fraction of a step still sitting in the accumulator. The rendered position is always one interpolation behind the true simulation, which is imperceptible, and in exchange every frame shows smooth sub-tick motion.
This matters most for anything the eye tracks continuously — a panning camera, an easing transition between data states, the settling motion of a force layout. For discrete, snap-to-grid updates interpolation adds little, so it is fine to skip it there and keep the two-state snapshot only where smoothness is visible. The one thing never to do is interpolate by extrapolating past the current tick, which guesses at a future that the next simulation step may contradict and produces visible rubber-banding when the guess is wrong.
Edge cases & gotchas
- Backgrounded tabs. A throttled tab delivers one giant delta on return; clamp the delta before accumulating or the loop runs its full
MAX_STEPScatch-up and still lurches. The clamp plus the cap together keep the return smooth. - Non-linear integration. Euler integration accumulates error that a variable delta amplifies; a fixed step bounds that error, and switching to semi-implicit Euler or Verlet improves stability further at the same step size.
- Step size and stiffness. Stiff springs in a force layout can oscillate or blow up if the fixed step is too large relative to the spring constant; shrink the step or soften the constant rather than reintroducing a variable delta.
- Interpolation across teleports. When an object is repositioned discontinuously (a data reset, a re-seed), interpolating from its old position draws a false streak; flag such objects to snap for one frame.
- Multiple simulations. If several independent simulations share the loop, give each its own accumulator only if their step sizes differ; otherwise one drained accumulator drives them all in lockstep.
- Measuring with Date.now.
Date.now()is quantised to whole milliseconds and can step backward on a clock adjustment, corrupting the accumulator; always useperformance.now().
Frequently Asked Questions
Why does my physics behave differently on a 144Hz monitor?
Because the update multiplies by the raw frame delta, which is smaller and more frequent at 144Hz than at 60Hz, and any non-linear integration accumulates those differences into divergent trajectories. A fixed timestep advances the simulation by an identical increment every tick regardless of refresh rate, so the same starting state settles into the same result on any display.
What is the spiral of death and how does the cap prevent it?
When one slow frame queues several catch-up simulation steps, running those steps makes the frame even slower, which queues yet more steps next frame — a runaway feedback loop that freezes the tab. Capping the number of steps per frame and discarding the leftover accumulator caps the work per frame, so the loop briefly runs in slow motion but always recovers instead of spiralling.
Do I always need the interpolation step?
Only where the eye tracks continuous motion, such as a panning camera or a settling force layout, where snapping to the latest tick reads as a faint stutter at high refresh rates. For discrete or snap-to-grid updates, interpolation adds little and you can render the latest state directly. When you do interpolate, blend between the previous and current tick rather than extrapolating past the current one.
Related
- Frame Rate Stabilization Techniques — the parent guide to steady-frame loops.
- Diagnose Dropped Frames with PerformanceObserver — measuring which frames the loop misses.
- Preventing Memory Leaks in D3 Force Graphs — keeping a long-running simulation loop clean.