Works on Desktop, Broken on iPhone: Three Safari Gotchas That Aren't in the Docs


We built a scrollytelling bedtime-story player for dailybedtimestory.com: you tap “Listen,” the narration starts, words highlight in time with the voice, and each swipe snaps to the next scene. On desktop Chrome it was lovely. In Playwright — including Playwright’s WebKit — every check was green.

Then I opened it on an actual iPhone and it broke in three completely unrelated ways. Multiple audio tracks blasted at full volume on top of each other. The narration sometimes refused to start at all. And the slick scene-by-scene snap scrolling degraded into a plain, mushy page scroll.

What follows is the post-mortem: three iOS Safari behaviors that aren’t where you’d look for them, why each one passed every test we had, and the one debugging technique that actually found them.

Gotcha #1: iOS ignores HTMLMediaElement.volume

The player layers audio — a quiet music bed under the narration, with crossfades between scenes. The whole mix was built on audio.volume: duck the bed to 0.09, fade the outgoing track to 0, bring the incoming one up. Standard stuff.

On iOS, audio.volume is a no-op. Setting it does nothing. Every <audio> element that is playing plays at full system volume, period. So all my carefully ducked and faded tracks were just… all playing, all at full blast, all at once. That was the “multiple audio” report.

The fix is to stop thinking in volume and start thinking in transport. Exactly one element plays at a time, switched by a helper that pauses everything else:

function setExclusive(el) {
  for (const a of allTracks) {
    if (a !== el) a.pause();
  }
  el.play();
}

Layered audio — a bed humming under the narration — is simply not achievable this way on iOS. If you need it, you have to pre-mix the layers into a single file before they ever reach the browser.

Gotcha #2: play() only works inside the gesture call stack

With the audio engine rewritten, narration still sometimes didn’t start when you tapped “Listen.” Not always — just often enough to look like a race.

It was a race, but not the kind I expected. iOS only honors audio.play() when it’s called synchronously inside the user-gesture handler. The moment you defer it — into a requestAnimationFrame callback, a setTimeout, a .then() after an await — iOS treats it as programmatic autoplay and silently blocks it. No error, no rejected promise you’d notice. Just silence.

My code looked reasonable: on tap, kick off a short fade, and start the narration when the fade finished. That handoff put play() one requestAnimationFrame outside the gesture stack — far enough for iOS to reject it.

The rule is blunt: call play() synchronously on the tap, do everything else afterward.

button.addEventListener("click", () => {
  narration.play();   // synchronous — inside the gesture
  startFade();        // fades, scroll, anything else can come after
});

There’s a corollary worth knowing. You can “unlock” other audio elements for later programmatic control by priming them once during that first gesture — play and immediately pause, synchronously:

for (const a of allTracks) {
  a.play().catch(() => {});
  a.pause();
}

After that prime, you can drive those elements from code without another tap.

Gotcha #3: no scroll-snap on the root <html>/<body>

The last failure was the most disorienting because the code looked correct. I had scroll-snap-type: y mandatory on the page, each scene sized to exactly 100vh, scroll-snap-align: start on the children. On desktop it snapped beautifully, one scene per swipe.

On iPhone, the same page just scrolled. No snap. Sections parked halfway off-screen.

iOS Safari does not honor scroll-snap-type on the root scroller — the <html>/<body> element. The spec allows it; Safari ignores it. The fix is to move the snapping sections into a dedicated scroll container and let that element own the snap:

.snap-scroll {
  position: fixed;
  inset: 0;
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
  -webkit-overflow-scrolling: touch;
}
.snap-scroll > section {
  height: 100%;
  scroll-snap-align: start;
}

Fixed background and overlay layers stay outside this container so they don’t scroll with it. One thing that does keep working: IntersectionObservers with the default (viewport) root, because the container fills the viewport exactly. The only thing that needs rewiring is any code reading window.scrollY / document.scrollingElement — those now have to read the container’s scrollTop instead.

The meta-lesson: Playwright WebKit is not iOS

Here’s the part that cost me the most time. Playwright’s WebKit reproduced none of these. It happily honored volume, allowed deferred play(), and snapped on the root element. Every fix I made looked verified before I shipped it, and then the device said “still broken.” I burned several deploy-and-pray cycles trusting a “WebKit” engine that doesn’t share real iOS Safari’s restrictions.

What finally worked wasn’t a better emulator — it was looking at the truth on the actual device. I added a tiny diagnostic overlay to the page, gated behind a query param so it never ships to users:

if (location.search.includes("audiodebug")) {
  overlay.textContent = allTracks
    .map(a => `${a.id} v=${a.volume.toFixed(2)} ${a.paused ? "" : "PLAYING"}`)
    .join("\n");
}

Then I loaded ?audiodebug on the phone and screenshotted it. The overlay read back all four tracks as v=1.00 — there was Gotcha #1, in black and white. A ?scrolldebug variant printed scrollTop % vh = 671 (not 0), which is what nailed the root-snap problem. I stopped guessing because I could see the device’s actual state.

If you take one thing from this: when you get a “works on desktop, broken on iOS” report, don’t reach for the emulator. Put a query-param-gated debug overlay on the page and screenshot it on the real hardware. It turns three days of speculation into three screenshots.

Takeaways

  • audio.volume does nothing on iOS. Mix by play()/pause(), one track at a time, or pre-mix into a single file.
  • play() must be synchronous inside the gesture. Anything deferred past the call stack is blocked silently.
  • No scroll-snap on the root element. Use a dedicated position: fixed scroll container.
  • Playwright WebKit ≠ iOS Safari. It won’t reproduce any of the above. A debug overlay screenshotted on the real device is the fastest path to the truth.