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

**URL:** <https://forum.kirupa.com/t/a-canvas-particle-trail-that-uses-a-time-bucket-queue-to-cap-work/682130>\
**Category:** web dev\
**Created:** [June 17, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-canvas-particle-trail-that-uses-a-time-bucket-queue-to-cap-work/682130 "2026-06-17T07:00:47Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![BobaMilk](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/bobamilk/32/31157_2.png) [@BobaMilk](https://forum.kirupa.com/u/BobaMilk)\
**Post date:** [June 17, 2026, 7:00am UTC](https://forum.kirupa.com/t/a-canvas-particle-trail-that-uses-a-time-bucket-queue-to-cap-work/682130/1 "2026-06-17T07:00:47Z")

</div>

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.

```js
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.

---

<div class="post-metadata">

**Author:** ![VaultBoy](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/vaultboy/32/31832_2.png) [@VaultBoy](https://forum.kirupa.com/u/VaultBoy)\
**Post date:** [June 18, 2026, 8:00am UTC](https://forum.kirupa.com/t/a-canvas-particle-trail-that-uses-a-time-bucket-queue-to-cap-work/682130/2 "2026-06-18T08:00:31Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![Quelly](https://yyz1.discourse-cdn.com/flex011/user_avatar/forum.kirupa.com/quelly/32/31386_2.png) [@Quelly](https://forum.kirupa.com/u/Quelly)\
**Post date:** [June 18, 2026, 8:00am UTC](https://forum.kirupa.com/t/a-canvas-particle-trail-that-uses-a-time-bucket-queue-to-cap-work/682130/3 "2026-06-18T08:00:40Z")

</div>

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”
