Your canvas game is slow because of one line, and it isn't the one you think

By Brandon Levy · Neon Drift devlog · August 2026

Three separate players reported three separate performance problems in my game. All three turned out to be the same bug. The fix took minutes each time; finding it took weeks, because I kept measuring the wrong thing.

I build Neon Drift, an arcade game that runs entirely in a 2D canvas. Over a few months I got three unrelated-sounding reports:

Different players, different features, different symptoms. One cause: ctx.shadowBlur is charged per drawing call, not per path.

What that actually means

Canvas 2D gives you a glow with three properties: shadowColor, shadowBlur, and whatever you draw next. It reads like a style, the same as fillStyle. Setting a fill colour costs nothing, so it is natural to assume setting a blur costs nothing either.

It doesn't. On a GPU-backed canvas, every fill() or stroke() issued while shadowBlur is non-zero triggers a separate blur pass. The cost attaches to the call. Ten calls with a blur set cost ten blur passes, even if they draw ten tiny identical dots in the same place.

Which means this innocent-looking loop is expensive in a way that scales with your content:

for (const p of particles) {
  ctx.save();
  ctx.globalAlpha = p.life;
  ctx.shadowColor = p.color;
  ctx.shadowBlur  = 8;
  ctx.fillStyle   = p.color;
  ctx.beginPath();
  ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
  ctx.fill();          // <- one blur pass, every particle, every frame
  ctx.restore();
}

That is real code from my game. It looks like a normal particle renderer. It was the cause of the third bug on that list.

Count calls, don't time them

The reason this took me so long is that I reached for a profiler first, on a desktop machine, and the profiler told me everything was fine. It was fine — on desktop. A desktop CPU rasteriser prices a blur completely differently from a mobile GPU compositor, so a design that costs 0.3 ms on a laptop can cost tens of milliseconds on a phone.

What finally worked was giving up on milliseconds and counting calls instead. I wrote a fake canvas context that renders nothing and only tallies what it is asked to do:

function makeCountingCtx(counter) {
  const ctx = {
    _blur: 0, _stack: [],
    save()    { this._stack.push(this._blur); },
    restore() { this._blur = this._stack.pop() ?? 0; },
    beginPath(){}, moveTo(){}, lineTo(){}, arc(){ counter.arc++; },
    fill()   { counter.fill++;   if (this._blur > 0) counter.blurred++; },
    stroke() { counter.stroke++; if (this._blur > 0) counter.blurred++; },
    // ...the rest of the 2D API, all no-ops
  };
  Object.defineProperty(ctx, 'shadowBlur', {
    get() { return this._blur; },
    set(v) { this._blur = v; },
  });
  return ctx;
}

Then run each piece of drawing code against it for sixty frames and divide. It takes about an hour to write, runs in Node with no browser, and produces a number that means the same thing on every device. A count is not an estimate of cost — it is the cost, in the only unit that transfers between machines.

The save/restore stack matters, by the way. A blur set inside a save() block is popped on restore(), and if your counter doesn't model that you will attribute blur to the wrong draw calls entirely.

The three bugs, in numbers

1. VAPORGRID: 80 blurred strokes per frame

A pipe design drew a perspective grid — a horizon and a fan of vertical lines — as eighty individual strokes with a glow on each:

// before
for (const line of lines) {
  ctx.beginPath();
  ctx.moveTo(line.x0, line.y0);
  ctx.lineTo(line.x1, line.y1);
  ctx.stroke();        // 80 of these, each a blur pass
}

Every one of those lines is the same colour and the same width. Canvas lets you build one path out of many disconnected segments and stroke it once:

// after
ctx.beginPath();
for (const line of lines) {
  ctx.moveTo(line.x0, line.y0);
  ctx.lineTo(line.x1, line.y1);
}
ctx.stroke();          // one blur pass, identical output

Eighty blur passes became one. The pixels are indistinguishable.

The player's description was "stuttering that increases the longer I use it", which sent me looking for a leak — something growing frame over frame. Nothing was growing. Eighty blurred strokes per frame is simply more than that phone could sustain, and what actually increased over time was thermal throttling. The bug was constant; the phone got slower. Worth remembering when a report contains the word "increasing".

2. The cherry blossom tunnel: 615 blurred fills per frame

A decorative tunnel drew a sakura tree using a recursive branch function, and each branch tip drew blossoms, and each blossom drew five petals — as five separate blurred fills:

// before: five petals, five blur passes, ~120 flowers on screen
ctx.shadowColor = '#ff6fae';
ctx.shadowBlur  = 6;
for (let k = 0; k < 5; k++) {
  ctx.save();
  ctx.rotate(k * Math.PI * 2 / 5);
  ctx.beginPath();
  ctx.ellipse(0, -r * 0.6, r * 0.33, r * 0.58, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

Measured: 615 blurred fills and 1,036 arcs per frame, against 4 for the next heaviest tunnel in the game. It was using well over half the frame budget on its own.

Batching the five petals into one path would have been a 5x win. Instead I baked each flower into an offscreen canvas once and stamped it with drawImage:

const cache = new Map();
function flowerSprite(radius, pale) {
  const key = radius + (pale ? 'p' : 'd');
  let cv = cache.get(key);
  if (cv) return cv;
  cv = document.createElement('canvas');
  cv.width = cv.height = Math.ceil((radius * 1.18 + 10) * 2);
  const g = cv.getContext('2d');
  g.translate(cv.width / 2, cv.height / 2);
  g.shadowColor = '#ff6fae';
  g.shadowBlur  = 6;              // paid ONCE, at bake time
  g.beginPath();
  for (let k = 0; k < 5; k++) {
    const a = k * Math.PI * 2 / 5;
    g.ellipse(Math.sin(a) * radius * 0.6, -Math.cos(a) * radius * 0.6,
              radius * 0.33, radius * 0.58, a, 0, Math.PI * 2);
  }
  g.fill();
  cache.set(key, cv);
  return cv;
}

drawImage of an already-blurred bitmap does no shadow work at all. The blur happens once, when the sprite is created, and every subsequent frame pays an ordinary composite. 615 blurred fills became 2.

One detail that is easy to get wrong: bucket sprites by rounded size rather than scaling one master. My source blur was a fixed 6 pixels at every flower size. If you bake one large sprite and scale it down, the halo scales too, and small blossoms stop reading as blossoms. Caching a handful of size buckets costs a few kilobytes and keeps the art correct.

3. Flap particles: up to 185 blurred fills per frame

This is the loop from the top of the article. Every tap spawned between 5 and 37 particles depending on the equipped skin, each living about 0.6 seconds, each rendered as its own blurred fill.

SkinParticles per tapAlive while tapping fast
Default5~25
Inferno / Glitch / Mech15~75
Freedom Flyer37~185

Because particles are spawned by input, the frame cost scaled directly with how fast the player tapped. That is exactly the shape of "it's worse if I tap fast", and it is why the bug felt like an input problem rather than a rendering one.

Same fix as the blossoms: pre-render a glow sprite per colour and size bucket, then drawImage. Blurred draw calls per frame went from one-per-particle to zero.

I also found the particle array had no upper bound at all — the ceiling was however fast a human could tap. If you have a particle system, go and check right now whether yours has a cap. Mine was five years of good luck away from a problem.

The multiplier nobody checks

Once I had a counting harness, I profiled every design in the game and they all looked acceptable. Then I noticed the harness was measuring one pipe, and the game draws three at a time.

One design, TOXIC, drew acid drips with a glow — one fill per drip, one per droplet bead, one per bubble. Eighteen blurred fills per side, thirty-six per pipe pair. With three pipe pairs on screen at the highest difficulty:

MeasurementBlurred calls
TOXIC, per pipe pair (passes its budget)36
TOXIC × 3 pairs on screen108
VAPORGRID, when a player complained80

It passed every per-design check and was still the most expensive thing in the game. If you build a budget check, check the worst-case frame, not just the worst-case component. The multiplier is where the damage is.

Batching the drips and bubbles into two paths took it from 36 to 4.

The trap when you batch arcs

When you merge many shapes into one path, circles need care. This is wrong:

ctx.beginPath();
for (const b of bubbles) ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fill();

arc() continues the current path, so each circle is joined to the previous one by a straight line. In my case the acid drips grew visible tails. Move first:

ctx.beginPath();
for (const b of bubbles) {
  ctx.moveTo(b.x + b.r, b.y);      // lift the pen to the arc's start
  ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
}
ctx.fill();

Also worth knowing: overlapping subpaths at partial alpha behave differently batched than separately. Two overlapping semi-transparent circles drawn as two fills double up where they cross; drawn as one path they don't. Usually that looks better, but it is a change, so look at it.

What to do with this

If you have a canvas game that feels rough on phones and fine on your development machine, the fastest useful thing you can do is stop timing and start counting:

  1. Count blurred draw calls per frame. A stub context takes an hour to write and gives you a device-independent number.
  2. Find loops that set shadowBlur and then fill once per item. That is the shape of the bug, every time.
  3. Batch same-styled shapes into one path and one fill. Free, and pixel-identical.
  4. Bake anything repeated into an offscreen sprite and drawImage it. The blur becomes a one-time cost.
  5. Check the worst-case frame, with every element on screen at once and multiplied by how many of each are drawn.
  6. Cap anything unbounded — particles especially.

I now run a budget script that profiles every design and fails if any of them, or the combined worst-case frame, goes over. It found the TOXIC problem the first time I ran it, after I thought I was finished.

The broader lesson I keep relearning: I diagnosed all three of these wrongly at first, and every wrong diagnosis came from reasoning about the code instead of measuring it. The code looks fine. It looks like a normal particle loop. The number is what tells you.

Neon Drift is free to play in a browser and on Google Play. More on how it's built in the devlog.