A canvas particle trail that uses a time-bucket queue to cap work

Yo folks, I’m bobamilk and I’m messing with a small canvas toy at lunch. I wanted a particle trail that looks smooth, but I also want runtime to stay predictable when input spikes. My failure mode was “looks cool for 10s then CPU climbs” because I kept too much history.

const buckets = 120; // ~2s if step=16ms
const stepMs = 16;
const q = Array.from({ length: buckets }, () => []);
let head = 0;
let last = performance.now();

export function addParticle(p) {
  q[head].push(p);
}

export function tick(ctx, now = performance.now()) {
  while (now - last >= stepMs) {
    head = (head + 1) % buckets;
    q[head].length = 0; // drop old bucket in O(1)
    last += stepMs;
  }

  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = "rgba(0,0,0,0.12)"; // fade
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  ctx.fillStyle = "rgba(120,220,255,0.9)";
  for (let i = 0; i < buckets; i++) {
    for (const p of q[i]) {
      ctx.fillRect(p.x | 0, p.y | 0, 1, 1);
    }
  }
}

Neat part is I can reason about complexity: memory is O(buckets + particles_in_window) and cleanup is O(1) per step, but draw cost is still O(particles_in_window). I’m thinking about sampling or per-bucket pixel stamping next if draw becomes the bottleneck.

this is basically a ring-buffer “despawn timer” for particles — like giving every dot a 2s lifespan so you can’t accidentally hoard history forever like a cursed roguelike run

one thing that helped me a ton on a similar canvas scribble toy: don’t store {x, y} objects per particle. packing coords into typed arrays per bucket (even just x/y arrays + a write index) cuts GC churn hard, and iterating is way faster than walking a bunch of little objects.

i’m curious where addParticle is coming from though — if it’s pointermove at full event rate, those events can come in way faster than 60hz, so buckets get stuffed instantly. i usually do a dumb distance gate like “only add when you’ve moved >= 1–2px since last point” and it keeps the trail looking smooth without turning into a particle firehose.

ring buckets is a super sane way to keep the “trail” from turning into an accidental time-series database, lol.

the only thing that’s bitten me with this pattern is allocation churn: q[head].length = 0 is O(1), but you’re still creating a bunch of tiny {x,y} objects and then nuking a whole bucket’s worth at once, which can show up as little GC hiccups when input gets spicy. when I did a similar canvas scribble thing, switching each bucket to a flat Float32Array (or even just two plain JS arrays for x/y) and reusing an index made frame pacing feel way less jittery.

curious what your addParticle cadence is—are you pushing on raw pointermove (which can be way >60hz on some devices) or are you already sampling to stepMs before enqueueing? that’s usually the difference between “draw is the bottleneck” vs “why is my heap graph doing cardio”