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

Two clocks joined by an accumulator Variable-rate render frames feed an accumulator that drives fixed simulation steps, and a leftover fraction interpolates the rendered state. Fixed simulation, variable render render frames (variable) accumulator += delta drain in 16.67ms steps, cap 5 sim steps (fixed) alpha = accum / step interpolates the render
Render frames arrive at irregular intervals and feed an accumulator; the simulation drains it in fixed steps, and the leftover fraction becomes an interpolation factor so rendering stays smooth between ticks.
Per-frame time vs the 16.7ms budget Measured-style frame durations with bars over budget marked in red. frame #ms/frame16.7ms budget (60fps)
Frame durations for simulation from render with fixed timestep against the 16.7ms (60fps) budget — the red bars are the janky frames a fix must remove.

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_STEPS catch-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 use performance.now().
Batched vs interleaved layout reads Reflow counts when reads and writes are batched versus interleaved. forced reflowsbatched read4batched write5interleaved read62interleaved write63
Why simulation from render with fixed timestep pays off: batching reads and writes (green/blue) keeps forced reflows low; interleaving them (red) multiplies layout work.

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.