Every simulation on this site uses a
requestAnimationFrame (rAF) loop. The reasons go beyond
"it's the modern way" — there are concrete, measurable differences
that affect correctness, performance, and user experience.
What's Actually Wrong With setInterval?
setInterval(fn, 16) fires every 16ms whether or not the
browser is ready to render. This creates three problems:
- Not synchronised to vsync. The browser's compositor paints at 60/90/120Hz (whatever the display supports). setInterval fires independently, causing tearing and wasted work when updates happen between paint cycles.
- Runs in hidden tabs. When the user switches away, setInterval keeps running — consuming CPU, burning battery, and accumulating simulation time that fires all at once when the tab becomes visible again.
- Timer drift. JavaScript timers are not real-time. They can be delayed by garbage collection pauses, layout work, or input-handler delays. A 16ms interval might fire at 16, 17, 14, 22ms in practice.
What requestAnimationFrame Provides
rAF callbacks receive a DOMHighResTimeStamp parameter
with sub-millisecond precision (typically). The callback fires at the
display refresh rate — the browser schedules it just before the next
paint, in sync with the compositor.
- Automatic throttling. When the tab is backgrounded, rAF pauses automatically. On some browsers it drops to ~1fps to keep timers alive but doesn't consume meaningful CPU.
- Vsync alignment. The callback fires at the right moment in the browser's render pipeline: after layout, before composite. Mutations inside rAF don't force extra layouts.
-
Accurate delta time. Because the timestamp is
provided, you can compute
dt = t - prevTand multiply all physics updates by it. This makes physics frame-rate independent.
The Correct Pattern
let prevTime = performance.now();
function loop(time) {
const dt = Math.min((time - prevTime) / 1000, 0.05); // seconds, clamped to 50ms
prevTime = time;
update(dt); // physics step — multiply accelerations by dt, velocities by dt
render(); // draw
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); // start
The Math.min(..., 0.05) clamp is important: if the tab
was hidden and then returned, dt could be several
seconds, causing objects to teleport. Clamping limits max physics step
to 50ms so a brief lag doesn't explode the simulation.
Fixed Timestep (for deterministic physics)
For stable physics (collision detection, spring dynamics), a variable
dt can cause instability at low FPS. Use a fixed timestep
accumulator:
const FIXED_DT = 1 / 60; // 60Hz physics
let accumulator = 0;
function loop(time) {
const frameTime = Math.min((time - prevTime) / 1000, 0.1);
prevTime = time;
accumulator += frameTime;
while (accumulator >= FIXED_DT) {
physicsStep(FIXED_DT);
accumulator -= FIXED_DT;
}
render();
requestAnimationFrame(loop);
}
This runs as many physics steps as needed to "catch up" to real time,
while each step is always exactly 1/60s. Render may
happen between steps — use the leftover
accumulator / FIXED_DT fraction to interpolate render
state for smooth visuals even at 120Hz.
✅ requestAnimationFrame
- Vsync-aligned, no tearing
- Auto-pauses in hidden tabs
- Accurate high-res timestamp
- Browser-optimised scheduling
- Uses less CPU when hidden
❌ setInterval
- Not vsync-aligned
- Runs in hidden tabs (battery drain)
- Timer drift under load
- No built-in delta time
- Can cause "spiral of death" catch-up
Rule: Never use setInterval for
animation. Use requestAnimationFrame with a delta-time
accumulator for variable-rate rendering, and add a fixed-timestep
inner loop for physics stability.