Best practices for real-time reactive visual effects
Hard-won notes for building Canvas 2D / Web Audio effects and game VFX that stay crisp and fast, even when spawning thousands of short-lived elements. This is the "how we built it / what we learned" reference for Ideas Visualized.
1. The ghosting / burn-in problem (and the real fix)
Symptom
After an effect traverses the canvas it leaves a faint ghost trail in the space it passed through. Over time these residuals accumulate and wash out the whole image into a muddy haze. It looks like the canvas is "remembering" old frames.
Root cause: trail-fade by overdraw + 8-bit rounding
Most trail effects don't clear the canvas each frame. Instead, to get motion blur cheaply, they paint a semi-transparent background rectangle over the previous frame:
// Common trick, creates trails but causes burn-in
ctx.fillStyle = "rgba(5, 6, 10, 0.12)";
ctx.fillRect(0, 0, w, h);
Each frame, a pixel of color C becomes:
newC = C * (1 - 0.12) + bg * 0.12
The canvas stores 8 bits per channel (0 to 255). Once C is within a few
levels of bg, that multiply rounds back to the same integer, so the pixel
never actually reaches the true background. The leftover 1 to 3 levels are a
permanent ghost. Anything using globalCompositeOperation = "lighter"
(additive) makes it worse, because it keeps adding brightness with no decay.
The "overlay I made to keep it fresh" = a periodic fully-opaque clear that resets the accumulated residual. It works, but it's a workaround.
Fixes (in order of preference)
A. Full clear every frame + explicit trails (default for new effects). Clear completely, then render trails as real geometry from stored history. Crisp forever, no burn-in.
ctx.clearRect(0, 0, w, h); // zero residual
for (const p of particles) {
// draw p.history as a fading polyline / dots
for (let i = 0; i < p.history.length; i++) {
const a = i / p.history.length; // 0..1 fade
ctx.globalAlpha = a;
ctx.fillRect(p.history[i].x, p.history[i].y, size, size);
}
}
ctx.globalAlpha = 1;
B. Keep overdraw trails but make them honest. If you want the cheap motion-blur look, accept that pure overdraw asymptotes. Mitigate by:
- Using a higher fade alpha (≥ 0.2) so residual sits below perceptible levels.
- Doing a true clear on a cadence (e.g. every Nth frame, or on resize / scene change), a built-in version of the "overlay reset."
- Never combining overdraw fade with
lighteron the persistent layer.
C. Two-layer compositing. A persistent "trail" canvas decayed deliberately, plus a fully-cleared "crisp" canvas on top for sharp elements. More control, a bit more cost.
Our default policy
- Effects that don't need trails:
clearRectevery frame. (e.g. ambient particle network, already ghost-free.) - Effects that want trails: full clear + explicit history-based trails, with
an optional
trailcontrol that adjusts history length, not fade-overdraw. - The shared canvas harness exposes a
clearModeso this is correct by default.
2. Object pooling (why reuse beats create/destroy)
The counter-intuitive part
"If the objects still exist when pooled, how is that faster than deleting them?" Because the cost was never the objects sitting in memory. It was the allocation and garbage collection churn.
What actually happens without pooling
Spawning thousands of particles/enemies per second means thousands of new
allocations per second. When they "die," they become garbage. The JS garbage
collector must periodically stop your code, walk the heap, and free them.
Those GC pauses land on random frames and show up as stutter / dropped
frames, the worst kind of jank because it's unpredictable.
What pooling does
Pre-allocate a fixed array of objects once. Track which are active. When one dies, flag it inactive (don't free it). When you need a new one, grab an inactive slot and reset its fields instead of allocating.
class ParticlePool {
constructor(max) {
this.pool = Array.from({ length: max }, () => makeParticle());
this.count = 0; // active particles occupy [0, count)
}
spawn(init) {
if (this.count >= this.pool.length) return null; // cap reached
const p = this.pool[this.count++];
init(p); // reset x, y, vx, life, ...
return p;
}
update(dt) {
for (let i = this.count - 1; i >= 0; i--) {
const p = this.pool[i];
stepParticle(p, dt);
if (p.life <= 0) { // "destroy" = swap-remove, no GC
this.pool[i] = this.pool[--this.count];
this.pool[this.count] = p;
}
}
}
}
Why it wins
- No allocations in the hot loop → no GC pauses. Steady, predictable frames.
- Cache locality. Reusing the same objects keeps memory access patterns warm.
- Bounded memory. A pool also enforces a hard cap (great for perf scaling).
Trade-offs / gotchas
- Costs a fixed chunk of memory up front (size it to your worst case).
- You must fully reset every field on reuse, or stale state leaks into the "new" particle (a classic pooling bug).
- "Swap-remove" (move last active into the dead slot) avoids array shifting and keeps the active set contiguous.
This is the single biggest perf lever for games with many little units/enemies and for dense particle finales.
3. Other best practices we've learned
Frame loop & timing
- Always use
dt(delta time), never assume 60fps. Multiply motion by seconds elapsed so speed is consistent across devices/refresh rates. - Clamp
dt(e.g.min(dt, 0.05)) so a tab-switch or hitch doesn't teleport everything across the screen on the next frame. - For physics that must be deterministic, use a fixed timestep accumulator.
Canvas / DPR
- Scale the backing store by
devicePixelRatio(cap at ~2) for crisp rendering without exploding fill cost on 3x phones. - Re-create size-dependent state on resize via
ResizeObserver.
Throughput
- Batch draw calls: group by color/blend mode, set
fillStyleonce, draw many. State changes (shadowBlur, gradients,globalCompositeOperation) are expensive, so minimize switches. - Cull anything off-screen before drawing.
shadowBluris costly. Prefer pre-baked radial-gradient sprites for glow.- For heavy static sprites, pre-render to an offscreen canvas once and
drawImageit (cheaper than re-pathing every frame).
Adaptive quality
- Measure frame time; when it climbs, shed load: lower particle caps, drop
trail length, reduce DPR, simplify glow. (See
performance-config.tspatterns in AllRoadsLeadToCursor / Lovable'stracks.ts.) - Ship mobile vs desktop budgets; auto-detect and downgrade gracefully.
Audio-reactive specifics
- Precompute band/beat data offline when the track is fixed (Lovable's
generate-audio-analysis.mjs) → perfectly stable, no per-frame FFT cost. - Use a live
AnalyserNodeonly when the audio is dynamic/user-supplied. - Smooth band values (lerp) so visuals don't strobe on every FFT jitter.
Lifecycle hygiene
- Pause the rAF loop when the tab is hidden (
visibilitychange) and when the effect scrolls off-screen (IntersectionObserver). - Respect
prefers-reduced-motion: render a calm/static state. - Always return a cleanup that cancels rAF and removes listeners.
4. Checklist for any new effect
- Uses
dt, clamps it - Correct
clearMode(full clear unless trails are intended; trails are explicit, not overdraw burn-in) - Pools objects if it spawns/destroys frequently; resets all fields on reuse
- DPR-aware, resizes cleanly
- Minimal state switches; glow via sprites not
shadowBlurwhere hot - Off-screen elements culled
- Pauses when hidden / off-screen; honors reduced-motion
- Cleans up on unmount