← All articles
programming · September 7, 2026

Your microphone cleanup never runs on iOS

I built a browser guitar tuner. Open the page, allow the microphone, tune. Then I noticed something on my phone: after using it, the orange recording dot stayed on. Not while the tab was open — after I closed it. Safari was still holding the microphone, and it kept holding it until I force-quit the browser.

The cleanup code was right there and looked correct:

window.addEventListener("pagehide", () => {
  stream.getTracks().forEach((t) => t.stop());
  void context.close();
});

That is the standard advice. beforeunload and unload are unreliable on mobile, so you use pagehide instead. Every article on the topic says so.

The problem is that it never runs

WebKit bug 199854: Safari on iOS doesn’t fire any events when the tab or app is closed. Not pagehide, not beforeunload, not visibilitychange. Nothing.

The reason is the freeze. When you background a tab on iOS, the page stops executing. When you later swipe it away in the tab switcher, there is no running JavaScript context left to notify — the page was suspended minutes ago and gets discarded without ever waking up. Your teardown handler is registered against an event that will not arrive.

So the shape of the bug is worse than “cleanup didn’t happen at the right time.” The cleanup was unreachable on the platform. On desktop it worked fine, which is exactly why it survived review: Chrome and Firefox do fire pagehide on tab close, so the leak never persisted there and nobody noticed.

visibilitychange is the last event you get

The one event iOS reliably delivers is visibilitychange when the page goes hidden — the moment you switch apps or tabs. That fires before the freeze. It is your last chance to touch the microphone, so that is where the release has to happen:

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    releaseMic();
    return;
  }
  // came back — re-acquire
});

There is a constraint hiding in here that decides the whole design. My first instinct was a grace period: don’t kill the mic the instant someone glances at a notification, wait a couple of seconds and release only if they haven’t come back. That is impossible. Once the page is hidden, iOS stops running timers and microtasks. A setTimeout scheduled inside the handler will never fire, and neither will anything after an await. Whatever you do has to be fully synchronous, inside that one callback.

So there is no soft version of this. You release immediately, or you leak.

Which means you need a resume path

Releasing on every backgrounding is aggressive for something people hold a guitar in front of. Glance at a message, come back, mic’s gone.

Re-acquiring on return mostly works — the permission is already granted, so getUserMedia won’t re-prompt. But the AudioContext is the catch: iOS wants a user gesture to start one, and coming back to a tab isn’t reliably a gesture. So the honest approach is to try, and have a visible fallback when it doesn’t take:

if (wasLive && !mic) {
  void startMic({ auto: true }); // leaves a "Resume" button if it fails
}

Two details worth stealing:

  • The automatic re-acquire skips the analytics event that the manual start fires. Otherwise every app-switch inflates your permission-grant funnel.
  • A failed auto-resume must not dump the user into your no-microphone fallback UI. They didn’t deny anything. Leave the resume button and let them tap it.

I also moved the panel swap into the pagehide path. It costs nothing, since the page is already hidden, and it means a back-button restore from the bfcache comes back to an honest “Resume” button instead of a live-looking tuner wired to a dead stream.

Test the thing you can actually test

I wanted a browser test asserting that every MediaStreamTrack ends up in readyState: "ended" after the page goes hidden. To see the tracks from the test side, I wrapped navigator.mediaDevices.getUserMedia in a Playwright init script.

Don’t. Any JavaScript wrapper around getUserMedia makes Chromium’s fake capture device hang — the promise never settles and the page sits there forever. It took me longer than it should have to catch this, because I was assuming my wrapper was the problem right up until I ran the unmodified baseline test and watched it fail identically. The fake-media harness had been broken for a while by a Chromium version bump, entirely separate from anything I was doing.

The lesson generalizes past this specific bug: when a test fails while you’re debugging, check that it passed before you started. I burned two cycles attributing a pre-existing breakage to my own change.

What worked instead was making the microphone module take injectable dependencies:

export async function openMic(windowSize, deps = defaultDeps()) {
  const stream = await deps.getUserMedia({ audio: {/* … */} });
  const context = new deps.AudioContextCtor();
  // …
}

Now the teardown sequence — every track stopped, the graph disconnected, the context closed, and the whole thing idempotent because two teardown paths can now race — is a plain unit test with fakes, no browser required. I verified it was a real test by reverting the fix and watching it fail.

The short version

If your web app touches the microphone or camera:

  1. pagehide alone is not cleanup on iOS. It is dead code there.
  2. Release the device on visibilitychange → hidden, synchronously.
  3. Make your stop() idempotent, because now two paths can call it.
  4. Give people a way back in, and don’t let the resume path pollute your funnel or your error states.

The permission dialog asks once. The user assumes closing the tab ends it. On iOS, that assumption is wrong unless you do this.

← All articles