Four wrong answers: debugging a stutter that turned out not to be my bug
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:
- the interval between animation frames, and its median
- time spent inside the game's own per-frame work
- a timestamp for every tap
- how many long frames occurred shortly after a tap, versus at any other time
That last line is the one that matters. Two completely different bugs feel identical here and need opposite fixes:
| Cause | Long frames near taps? | Fix |
|---|---|---|
| A real cost spike | Yes | Find and batch the expensive thing |
| Cadence judder | None at all | Interpolate 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:
| Measurement | Value |
|---|---|
| Long frames within 150 ms of a tap | 0.628 per 100 ms |
| Long frames at any other time | 0.003 per 100 ms |
| Taps producing a dropped frame | 93.3% |
| Time spent in the game's own frame work | 1 ms median, 5 ms max |
| Time spent in the tap handler | 0.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:
| Binding | Long frames per real touch |
|---|---|
Non-passive touchstart | 0.964 |
Passive touchstart | 0.986 |
pointerdown | 0.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.
| Page | Long frames per touch |
|---|---|
| Minimal page (three moving parts) | 1.028 |
| Minimal page + 3,000 extra DOM elements | 1.907 |
| My actual game | 0.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.