Animation speed is frame-rate dependent (no delta-time) — runs in slow motion when FPS dips

Author: martin-circlesCreated Jun 19, 2026Updated Jun 26, 2026

Summary

The particle simulation advances by a fixed step per frame rather than by elapsed time, so the animation's wall-clock speed is tied directly to the delivered frame rate. When the page's frame production is uneven (busy main thread, background tabs, variable-refresh displays, heavy compositing), the confetti visibly slows down and speeds up instead of playing at a constant speed.

Details / root cause (v1.9.4)

  • Velocity decays a fixed factor per frame: fetti.velocity *= fetti.decay (src/confetti.js, updateFetti).
  • Lifetime is counted in frames (ticks), not time: progress = (fetti.tick++) / fetti.totalTicks, alive while fetti.tick < fetti.totalTicks.
  • The rAF loop uses the timestamp only as a maximum frame cap: var TIME = Math.floor(1000 / 60) and if (lastFrameTime === time || lastFrameTime + TIME - 1 < time) { ... }. This prevents >60fps from running fast, but when frames arrive slower than ~16.6ms it still advances exactly one fixed step — so the effect plays in slow motion. There is no delta-time compensation.

Reproduction

  1. Fire confetti while the main thread is under variable load (e.g. a video-heavy app, or simply generate input/render churn on the page during the burst).
  2. Observe the animation speed fluctuate — slow, then normal, then slow — over a single burst. On a 120Hz display vs a throttled tab the difference is also visible.

Expected

The animation should play at a consistent wall-clock speed (e.g. its current ~60fps-equivalent rate) regardless of the actual delivered frame rate.

Suggested fix

Make the simulation time-based using the timestamp already passed to the rAF callback (onFrame(time)):

  • advance tick by delta / TIME instead of 1,
  • apply decay as velocity *= Math.pow(decay, delta / TIME),
  • scale gravity/drift contributions by delta / TIME.

To preserve backwards compatibility, this could be gated behind an opt-in option (e.g. frameRateIndependent: true on the global/fire options) rather than changing default behavior.

Notes

Not a duplicate of #114 (prefers-reduced-motion), #235 (pause at frame), or #230 (mobile). This is specifically about frame-rate independence of the simulation.

Environment: canvas-confetti 1.9.4, Chrome on macOS (also reproducible anywhere frame pacing is uneven).