·10 min read

Capacitor iOS game lag was HTML audio play()

My Capacitor game stuttered on a fast iPhone through three rounds of real fixes. The cause was <audio>.play() blocking the WebView for up to half a second.

gamedevcapacitoriosperformance

Contents

ASMR Logo Craft Block is a Capacitor game: React and Vite in a WebView, wrapped by Capacitor 8 for Android and iOS. On a recent iPhone with a 120 Hz screen, gameplay did not feel smooth. Drag a few blocks quickly and the board would stop, then carry on, as if the phone were slow. It is not a slow phone.

I spent three rounds fixing that. Every round found something real, measured it, and made a number better. None of them was what I could feel. The thing I could feel turned out to be the sound: every sound effect was played through an HTML <audio> element, and on the phone each play() call blocked the game's JavaScript: up to 335 ms in the first measurement, and over half a second later on. Playing the same files through Web Audio took the time frozen during a fast burst from 12.4–14.4 s down to 0.9–1.0 s.

This post is the long way round, because the wrong turns are the useful part.

Three rounds of real fixes

Round one went after paint cost. The dust and sparkle particles built a new canvas gradient for every particle on every frame; they now stamp sprites drawn once. A shadow that never changed was being animated anyway. On identical scripted play, the first batch of changes took the WebKit GPU process from 4.2 s of CPU to 2.7 s. The idle board's compositor work fell by about 30% once each tile stopped holding its own layer, and the home screen's glowing halo went from 7.0 s of page time per 30 s to 2.4 s.

I also added CADisableMinimumFrameDurationOnPhone to Info.plist to let the WebView run at 120 Hz. It made no measurable difference, so it came out again.

The same round found two genuine logic bugs. When I dropped a block before the previous one had finished landing, the old landing callback still fired: it snapped the new flight short and played the thump at the slot the block had already left. Eight back-to-back drops produced 16 landing effects in the old code and the 9 that really happen in the fixed one. And the board tracked a single drag, so a second finger took over the first. A new two-finger test counted 7 of 10 moves on the old code and all 10 after the fix.

In round two, I asked Claude Code to send four subagents at the effects code in parallel, each briefed as a different kind of specialist, and compare what came back. (I have written about running parallel subagents on bug hunts separately.) All four landed on the same thing: a name ring drawn by animating the angle of a conic gradient, which forces a full repaint every frame, with five to seven of them spinning at once during fast play. I switched effects off one at a time on a 10-move burst in desktop WebKit: shadows, dust, splats, shockwaves and the background changed nothing, and the rings did. Drawing the ring once and rotating it cut late frames from about 185 to about 55, and the GPU process from 10.6 s to 4.5 s.

Then I played it on the phone, and it still stopped and continued.

Most of those numbers came from the simulator and desktop WebKit, where a script can play exactly the same moves twice. They were real numbers. They were not the phone's numbers.

The clue I had twice and dropped

Looking back, the sound was named early. During round one I described the problem as the sound falling out of time with the moves. The stale landing callback explained that neatly, because it really did play thumps in the wrong place, so I stopped looking.

In round two I finally measured on the phone itself, and saw freezes from half a second to well over a second. An Instruments trace across all processes put audiomxd, the iOS audio daemon, among the busiest processes on the phone. I ran the burst with each effect switched off in turn, including a run with sound and haptics switched off through the settings screen. That run froze as badly as the others. Reading the settings back, only haptics had switched; sound still said on. I switched them back, and from then on every run was smooth, including after a fresh launch. I never found out why the freezes went away that day. The toggle is the obvious suspect, but I did not prove it then and I cannot now. I wrote the freezes down as intermittent and unexplained, and moved on to smaller hitches.

Timing play() on the phone

The third round started from the other end: before changing anything, measure every call that runs on a move, on the device. WebKit's remote inspector can drive a debug build's page from the Mac. I used appium-remote-debugger, the library Appium's iOS driver uses for this, to inject scripts, run a 10-move burst three times (each pick-up straight after the last drop, about 2.25 s of moves) and record every requestAnimationFrame timestamp.

Two things changed from round two. The sound and haptics switches were written straight into the game's stored preferences and read back before each run, so the control was a control. And play() itself was timed:

// Simplified: run in the page through the inspector before the burst.
const P = HTMLMediaElement.prototype;
const original = P.play;
window.audioCost = { plays: 0, maxMs: 0, totalMs: 0 };
P.play = function (...args) {
  const t = performance.now();
  try { return original.apply(this, args); }
  finally {
    const ms = performance.now() - t;
    audioCost.plays++;
    audioCost.totalMs += ms;
    audioCost.maxMs = Math.max(audioCost.maxMs, ms);
  }
};

play() returns a promise, so in principle it should cost nothing on the calling thread. On the phone, the slowest call in each of the three bursts took 176 ms, then 254 ms, then 335 ms. It got worse the longer the game ran.

Before touching any code I backed up the game's saved data on the phone, because the test seeds overwrite progress, and restored it afterwards. Then the four runs, three bursts each. "Time frozen" is every frame's time beyond 16.7 ms, summed:

3 bursts of 10 fast moves Frames drawn Worst frame Time frozen
Sound on 88 1,358 ms 13.4 s
Sound off 842 50 ms 0.9 s
Sound on, haptics off 181 1,105 ms 11.6 s
Sound on, played through Web Audio 803 97 ms 1.6 s

The last row is a stand-in: I replaced window.Audio in the running page with an object that plays the same files through Web Audio, so it could be tested before any code was written. Each play cost at most 2 ms. Haptics made no difference at all.

Why it was <audio> in the first place

The sound came from a shared adapter that all my games use. On iOS it expected each clip to name a native playback method; a clip without one fell through to the HTML route, a small pool of new Audio(url) elements per sound. None of this game's ten clips named a native method, and the app had no native sound plugin anyway. So every move click, landing and chime went through a WKWebView <audio> element. Nothing failed. It played, and it was slow.

Decode once, play buffers

The fix was a new sound module that never touches <audio>. Each clip is fetched and decoded once when it is registered; playing it starts a buffer source on the audio thread. This is a simplified sketch of the idea, not the shipped module:

if (navigator.audioSession) navigator.audioSession.type = 'ambient';
const ctx = new AudioContext();
const buffers = new Map();

async function load(name, url) {
  // No response.ok check: see below.
  const data = await (await fetch(url)).arrayBuffer();
  buffers.set(name, await ctx.decodeAudioData(data));
}

function play(name, volume = 1) {
  const buffer = buffers.get(name);
  if (!buffer) return;                 // still decoding: skip it, never queue
  if (ctx.state !== 'running') ctx.resume();
  const source = ctx.createBufferSource();
  const gain = ctx.createGain();
  source.buffer = buffer;
  gain.gain.value = volume;
  source.connect(gain).connect(ctx.destination);
  source.start();
}

Call ctx.resume() from a touch handler as well: iOS keeps the context suspended until a user gesture, and it can suspend it again after a call or a trip to the background.

navigator.audioSession exists in the iPhone's WKWebView; reading it back on the device returned ambient. That setting changes behaviour you can hear. With ambient, the phone's silent switch mutes the game and music from another app keeps playing underneath. That is what the type is documented to do. I have not measured it, nor exactly what the <audio> route did before, but players will hear the difference either way.

The first fixed build was silent

The first A/B on the phone looked perfect: 0.9 s and 1.0 s frozen against the old build's 12.5 s and 12.4 s. It also counted zero plays.

Two things were wrong. My counter patched start() on AudioScheduledSourceNode.prototype, but AudioBufferSourceNode defines its own start(), so the patch never ran. Fixed, it still counted zero, because the build really was silent. Under Capacitor's capacitor:// scheme, a fetch() of a file bundled with the app answers with status 0 and ok: false. The module checked response.ok, treated every clip as a failed download, and decoded nothing. A probe on the phone showed it plainly: status 0, 12,538 bytes, decoded fine.

A silent build is a perfect sound-off control, which is why it measured so well. It is the same trap as a check that passes while broken: an instrument that counts nothing looks exactly like one that found nothing. Now any file that is not audio, a missing file included, fails at decodeAudioData and is reported there.

Old against new on the phone

By then the phone reported thermal state Serious in the Instruments trace. I waited for it to cool; on the charging cable it stayed at Serious for the rest of the session. So I alternated the builds, old, new, old, new, so that whatever the heat did, it did to both.

On the phone, alternating builds Old: <audio> New: Web Audio
Time frozen, 10 fast moves ×3 12.4–14.4 s 0.9–1.0 s
Worst frame in a burst 1.2–1.9 s 50 ms
Sounds played per run, cost of one 71–78, up to 568 ms 69–81, 1 ms at most
One full level, solved by script 27.3 s 6.8–7.6 s

The new build with sound on matches the old build with sound off. The game's tests and device scenarios pass on the iOS simulator and the Android emulator too, but I measured smoothness on the iPhone only.

What is still open

The new build still pauses when a level ends. On a cool phone, the day before, those frames were 130–180 ms. With the phone at Serious and sound switched off entirely, they reached 1.3 s, so they are not the sound. The WebKit timeline shows the page waiting on compositing with almost no page work of its own, which suggests something outside the page, possibly native work at level end such as saving. I have not proven that, or anything else about them. Oddly, sound-off runs stalled longer at level end than the Web Audio runs did, and I have no explanation for that either. It gets its own investigation, on a phone that has cooled down.

Takeaway

Measure on the device before optimising anything. My first two rounds made real numbers better, on machines that did not have the problem. Time the calls that run on every move, including the ones that return a promise and look free. Read a control back before trusting it, because a switch that did not switch tells you nothing. And give a fix its own counter, because the cleanest result I got all week came from a build that played no sound at all.

More posts

New writing when there is something worth saying. By email or by RSS, whichever you prefer.