Four wrong answers: debugging a stutter that turned out not to be my bug

By Brandon Levy · Neon Drift devlog · August 2026

A player reported a small stutter every time they tapped. I was confidently wrong about the cause four times in a row. The useful part of this story isn't the answer — it's the four wrong ones, and what each measurement cost to get.

The report was specific and good, which is rarer than it sounds: "When I tap, there's the slightest stutter. If I spam taps, it's more noticeable." On an iPhone 17. Recent hardware, so not a device-power problem.

Wrong answer 1: it must be drawing cost

My game spawns particles on every tap. Reading the code, I found the particle renderer was issuing one blurred draw call per particle, uncapped — up to 185 per frame while tapping fast. That is a genuine bug and I have written about it separately.

It fit beautifully. Particles are spawned by input, so the cost scales with tap rate, which explains "worse if I spam". I fixed it, removed roughly 185 draw calls per frame, and asked the player to retest.

No change at all.

That null result was the single most valuable thing in the whole investigation. Eliminating 185 draw calls per frame and changing nothing doesn't just fail to confirm the theory — it rules out the entire category. Whatever was happening, frame cost wasn't it.

Building an instrument instead of another theory

At this point I stopped reading code and built a diagnostic overlay, enabled by a URL parameter, that recorded:

That last line is the one that matters. Two completely different bugs feel identical here and need opposite fixes:

CauseLong frames near taps?Fix
A real cost spikeYesFind and batch the expensive thing
Cadence judderNone at allInterpolate the render

Nothing in the source tells you which one you have. A measurement does, immediately.

Two notes on building instruments, both learned the hard way. First, define "long frame" relative to that device's own median interval, not a fixed millisecond figure, so the same code works on a 60 Hz phone and a 165 Hz monitor. Second — and I did exactly this — my first version computed a median by sorting a 600-element array on every frame. The instrument would have caused the stutter it existed to measure. Cache it and refresh a couple of times a second.

Wrong answer 2: ProMotion judder

Armed with the overlay, I formed a better theory. The simulation runs at a fixed 60 Hz; an iPhone 17 has a 120 Hz display. Each position gets shown twice, and if the cadence is uneven — twice, then three times, then twice — you get judder. It's the same reason 24 fps film looks jerky on a 60 Hz TV, and it's invisible until something moves fast, which is exactly what a tap causes.

It even predicted a test: a high-refresh desktop should show the same thing.

The desktop capture came back holding each position for three frames — worse than the phone's two — and it felt perfectly smooth. My theory predicted the opposite of what happened. Dead.

The capture did find something else, though: the game's simulation rate depended on monitor refresh rate, a real fairness bug that had been mis-scoped in my notes for two weeks. That's its own article. It was not this bug.

The phone capture, and what it ruled out

Getting JSON off an iPhone by hand is tedious enough that it was shaping my data — my first captures were twelve taps long, far too short to separate anything. So I added an upload button that posted the capture straight to a database. That one piece of convenience changed the investigation more than any hypothesis did, because it made 120-tap sessions free.

With real data, the tap correlation was unambiguous:

MeasurementValue
Long frames within 150 ms of a tap0.628 per 100 ms
Long frames at any other time0.003 per 100 ms
Taps producing a dropped frame93.3%
Time spent in the game's own frame work1 ms median, 5 ms max
Time spent in the tap handler0.08 ms average

Roughly every tap dropped a frame — and 97% of that frame was neither my drawing code nor my event handler. My code was using about 1 ms of a 17 ms budget. The time was going somewhere I wasn't measuring.

Wrong answer 3: the touch listener is blocking the compositor

Here I found something real. Touch handlers registered as non-passive let you call preventDefault(), and to honour that the browser must wait for your handler before it can decide whether the page scrolls. The standard fix is to declare touch-action: none in CSS and make the listener passive.

And I had a genuine defect: touch-action is not an inherited CSS property. I had set it on html, body and it never reached the canvas. So the only thing preventing scroll on my playfield was preventDefault() inside a non-passive handler — textbook.

I fixed it, shipped both bindings behind a flag, and had the player capture each.

The passive version looked twice as good. For about ten minutes I thought I'd found it.

Then the player mentioned in passing that in the passive build, each tap was flapping twice. Without preventDefault(), iOS still synthesises a mouse event after the touch, and I had both listeners attached. Every touch was counted as two taps — which doubled the denominator and halved the derived rate.

Correcting for it:

BindingLong frames per real touch
Non-passive touchstart0.964
Passive touchstart0.986
pointerdown0.947

Identical. The apparent win was entirely an artifact of my own bug. Without that offhand remark I would have shipped a "fix" that fixed nothing and believed it for months.

Two real things did come out of it: the double-flap bug got fixed by moving to pointerdown, which has no synthesised twin, and the canvas got its touch-action declaration.

Wrong answer 4: it's garbage collection

Allocation was the last plausible candidate. Each tap allocated 5–37 objects, and garbage collection runs between tasks — precisely where in-frame timers are blind. It would explain time that my instrumentation couldn't see.

So I added a flag that kept the tap working but allocated nothing. Result: 1.038 long frames per tap, versus 0.950 normally. Unchanged.

The control that ended it

I should have run this experiment first. It cost about ten lines.

I added a mode where the tap handler records the touch and returns immediately — no movement, no particles, no game logic — and sampled while the game sat idle on the menu.

Result: 1.267 long frames per tap. With the game doing nothing at all.

Then a minimal test page: a canvas, an animation loop, and one listener. Nothing else.

PageLong frames per touch
Minimal page (three moving parts)1.028
Minimal page + 3,000 extra DOM elements1.907
My actual game0.950 – 1.267

Safari and Chrome for iOS agreed to within 3%.

The answer is that iOS drops roughly one frame on every touch, before any application code runs, and my game was already at that floor. There was nothing to fix.

The heavy variant is the one genuinely actionable finding: DOM complexity nearly doubles the per-touch cost. My game measures level with a three-element page today, which is a property worth protecting as its menus grow rather than a problem to solve.

What I'd do differently

Run the control first. The ten-line "do nothing and measure" experiment would have ended this on day one. I ran it fifth, after four rounds of building fixes for causes I'd reasoned my way to. When something correlates with an action, the first question should be whether it survives removing everything that action does.

Treat a null result as data. Removing 185 draw calls and changing nothing was more informative than any of my positive theories. It ruled out a whole category. I nearly dismissed it as "didn't work" and moved on.

Fix the friction in measurement before the bug. Twelve-tap captures because uploading was annoying meant I couldn't distinguish warm-up from steady-state cost. An upload button was fifteen minutes and unblocked everything after it.

Watch for the instrument becoming the bug. Sorting an array every frame inside a stutter diagnostic is funnier in hindsight.

Listen for the offhand remark. "Each tap was flapping twice" was mentioned as an aside, almost as an afterthought, and it invalidated my most promising result. If someone tells you something odd about the build you gave them, that is data about your experiment, not a side issue.

Four wrong answers is not a great record. But each one was falsified by a measurement rather than by argument, and the total cost was a few evenings and a table I can hand to the next person who asks. The alternative — shipping the passive-listener "fix" and calling it done — would have felt much better and been much worse.

Neon Drift is free to play in a browser and on Google Play. More build notes in the devlog.