Piper-WASM on iOS: Three Failure Modes, and What Ships Today
Piper compiled to WebAssembly should, in principle, run anywhere a browser does. iOS Safari supports WebAssembly. Therefore Piper should run on iPhone. We tried this in Quick TTS, watched it fail in three different ways, and gated it off mobile for a while because of it. That gate is gone: Piper runs on mobile today, iPhones included. Here's what actually breaks, what we changed to ship it anyway, and what is still the platform's to fix.
What you'd expect
The story sounds plausible on paper. Piper exports its VITS models as ONNX. onnxruntime-web compiles to WASM. iOS Safari has shipped WebAssembly since iOS 11, supports SIMD since iOS 16.4, and runs reasonably standard HTML5 audio. A 60 MB voice model and a 10 MB runtime is not a large download by 2026 standards. Stick the inference loop in a Web Worker, pipe the resulting PCM into an AudioContext, ship.
That story is wrong in three specific places, and the failure modes compound. Each one would be solvable in isolation; together they make Piper-on-iOS the kind of feature that works for the developer testing it on Wi-Fi at full battery and fails for half the users who try it in the real world. Quick TTS shipped Piper on iOS, pulled it back behind a PLATFORM.isMobile gate, and later removed that gate once the mitigations below were in place.
What actually happens, in three failure modes
1. The AudioContext autoplay restriction
iOS Safari's strictest audio rule is older than WebAssembly itself: AudioContext output is suspended by default, and the only way to resume it is from a synchronous handler running inside a user-gesture event (touchend, click). The rule applies regardless of where the audio data came from — even PCM you generated in a Web Worker and passed back to the main thread will not play unless the AudioContext was already unlocked at some prior moment of user interaction.
The trap: synthesizing audio successfully and then watching it never play. The console is silent. The worker reports a finished generation. The blob exists. Nothing comes out of the speaker. We hit this on the very first iOS test pass; the symptom looks identical to a broken model load, which is the wrong direction to spend an afternoon debugging.
Quick TTS' workaround lives in app.js as _unlockMobileAudio: every play-button click fires a silent oscillator and an empty SpeechSynthesisUtterance first, both of which count as "user-initiated audio output" and quietly unlock the AudioContext for the rest of the session. That fix works for Web Speech (which we ship), and it would work for Piper if the other two failure modes weren't waiting downstream.
2. The 100 MB tab memory cliff
iOS Safari has a per-tab memory limit that is much more aggressive than desktop browsers. The exact number is undocumented and varies by device — historically WebKit has kept WebAssembly heap allocations under roughly 256 MB on a 4 GB iPhone and gets stricter on lower-RAM devices (the iPhone 11 ships 4 GB, the iPhone 12 mini ships 4 GB, several iPad Air SKUs ship 4 GB). The WebKit team has not published a stable budget; it's a moving floor that drops further when other tabs or apps are competing for memory.
Loading a 60 MB Piper voice + the onnxruntime-web WASM heap (~80 MB resident during inference) + the page's own JS heap consistently lands in the danger zone. iOS doesn't return an error when a tab crosses the limit; it silently kills and reloads the tab. The user sees a fresh page, no console error, no event the page can listen for. Sometimes the kill happens during model load, sometimes during the first inference, sometimes minutes later when the user comes back to the tab.
We logged this with anonymized analytics and found the kill rate on iOS Safari for the brief window we shipped Piper there was somewhere in the high-twenties percent. On Android Chrome it was lower (around 8%) but still well above any reasonable bar. There's no graceful degradation path for "your tab might get killed at any moment" — the only fix is not to allocate that much memory to begin with.
3. The single-threaded WASM tax
Desktop ONNX inference for VITS-class models leans heavily on multithreading; onnxruntime-web spawns a thread pool sized to the device's logical core count and runs the matrix operations in parallel. WASM threads require SharedArrayBuffer, which in turn requires the page to be served with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers (the COOP/COEP "cross-origin isolation" combo).
iOS Safari technically supports SharedArrayBuffer when those headers are set, but the support is fragile. Several iOS releases over the last three years have shipped with COOP/COEP regressions where the headers parse, the page becomes cross-origin-isolated by every test we can run, but navigator.hardwareConcurrency > 1 threads inside a Worker still execute serially. We've never been able to consistently reproduce a working multi-threaded inference pass on iOS, and the onnxruntime-web project's own issue tracker is dotted with reports matching that pattern.
The practical effect: a Piper inference call that takes 200 ms on desktop Chrome runs at 800 ms or longer on a recent iPhone. That's bad enough on its own; worse, single-threaded WASM blocks the main thread of the worker even though the worker itself is a separate thread, which means the AudioContext on the main thread can run dry while the worker is still computing. The audio audibly stutters in a way it doesn't on any other platform. iOS Safari's relatively low audio buffer size makes this worse, not better.
The same three failure modes, in one table
For comparison's sake, here's how each failure mode compounds across iOS Safari, Android Chrome, and desktop Chrome. The desktop column is what the code is implicitly designed for and where Piper actually works.
| Failure mode | iOS Safari | Android Chrome | Desktop Chrome |
|---|---|---|---|
| AudioContext gating | Strict; needs gesture-initiated unlock every session | Strict; same gesture rule, less aggressive in practice | Effectively absent; desktop Chrome unlocks on first interaction |
| Tab memory cliff | ~256 MB undocumented, drops on low-RAM devices, silent kill | ~512 MB practical ceiling, OOM error visible to the page | ~4 GB+ on most desktops; not a constraint for an 80 MB model |
| WASM threads | Flaky; SharedArrayBuffer present but threads often serialize | Functional with COOP/COEP headers | Functional; full thread pool available |
| Observed tab-kill rate during the rollout | High twenties percent | Around 8% | Statistical noise (<1%) |
Those numbers are from the original rollout and are the reason for the gate, not a description of today. Android was always the more tractable of the two — Chrome at least throws errors the page can catch, and its memory ceiling is roughly 2× iOS' on the same nominal RAM budget — so Android came back first, with iOS following once the memory floor below was in place.
What Quick TTS does about it (today)
The blanket mobile gate is gone. aiTtsPiperSupported() in aiTts.js checks for WebAssembly and then refuses only devices that explicitly report less RAM than AI_TTS_PIPER_MIN_DEVICE_MEMORY_GB (2 GB) through navigator.deviceMemory. That signal is Chromium-only, so it screens the crash-prone low-end Android tier; iOS and Firefox report nothing and are left enabled. PLATFORM.isMobile now survives in exactly one functional place — _piperPoolSize(), which pins mobile to a single worker instead of the desktop pool of up to three, so a phone never runs three copies of the runtime at once. That single-worker pin is the mitigation that made failure mode 2 survivable.
On Android specifically, the voice <select> is replaced with a "Change ↗" link that deep-links into system accessibility settings (com.google.android.tts.MainActivity) — Android exposes voices at the OS level rather than per-tab, so a per-page picker would lie to the user about which voice is actually active. iOS keeps its voice picker because Apple's voices enumerate cleanly through Web Speech.
The handoff machinery elsewhere in the codebase (covered in our Web Speech vs Piper vs Kokoro post) means that even on desktop, when Kokoro or Piper fails to generate or play a batch mid-read, the remaining text is handed transparently back to Web Speech. (If an engine fails at activation instead, the dropdown simply reverts before anything plays.) Mobile gets that same fallback, and — unlike during the gated period — it also gets the option to opt up in the first place.
What would still make this better
Shipping it did not make the three failure modes go away — it routed around them. Three platform-level shifts would turn that workaround into a proper fix. None of them are in our hands.
- Stable WASM threads. WebKit needs to ship WASM threading that works reliably on COOP/COEP-isolated pages and that scales to the iPhone's actual core count. The infrastructure is there; the implementation has been flaky across several iOS releases.
- A predictable per-tab memory budget. Either a published number ("Safari guarantees 256 MB of WebAssembly heap on iPhone 12 and later") or a stable lifecycle event when the OS is about to reclaim a tab. Today neither exists. Without one, a page can either allocate conservatively and underperform, or allocate aggressively and get killed unpredictably.
- A tab-eviction event the page can listen for. Even if the memory budget stays tight, an event fired before the kill — analogous to
visibilitychangeorpagehidebut specifically signaling memory pressure — would let a TTS app save its current chunk index, free the model, and resume from the same place when the user returns. Today the kill is silent and unsavable.
The instrumentation we wish we'd had earlier
If you're investigating mobile WASM crashes on your own product, the metrics that took us longest to wire up and helped most were:
- Tab-revisit detection. Set a sentinel value in
sessionStorageat the start of each Piper session and check for it on every page load. If the sentinel is present but the session is "new" (no fresh user interaction since), the tab was killed and reloaded. This is the only reliable way to detect the silent iOS kill from inside the page. - Stage-tagged failure events. Tag every failure analytics event with the stage at which it failed:
model_download,wasm_init,first_inference,playback_start,playback_mid. The distribution of failures by stage tells you which constraint is actually biting — model download failures are usually network, wasm_init failures are usually memory, mid-playback failures are usually thread starvation. - Device class bucketing. User-agent parse the iPhone model and bucket by RAM tier (4 GB, 6 GB, 8 GB). Tab-kill rates correlate strongly with device RAM, and a single global "iOS Safari fails 28% of the time" number obscures the fact that recent iPhone Pro models are usable while iPhone SE / mini class devices aren't.
- Performance.measure() instead of console.log. Each successful inference path emits four
performance.markcalls (start, voice-loaded, first-blob, last-blob). The DevTools Performance panel can read those even from a Safari Web Inspector remote-debug session, which is the only practical way to get timing data off a real iPhone.
iOS 18, iOS 19, and the actual timeline
Honest assessment of where the platform is right now:
- iOS 18 (shipped 2024) didn't fix it. WebGPU work landed in Safari Technology Preview; WASM threading and memory budgets didn't move. The same three failure modes apply.
- iOS 19 (shipped 2025) made WebGPU usable on the desktop side and Safari Tech Preview's WebGPU path is now stable enough that Kokoro could in principle run on a recent Mac. Apple has not made any public statement about WASM threading reliability or per-tab memory limits on the iPhone side, and our testing didn't find observable improvements there.
- What we did instead of waiting. None of the three platform issues has been fixed. We shipped mobile Piper anyway, by making the failure modes survivable rather than solved: a single worker on mobile, an explicit low-RAM floor, and the gesture unlock that was already there. Enabling it was a telemetry decision rather than a platform one — the failure rates stayed inside the band we were willing to accept, and the engine is instrumented so a regression surfaces as a rise in mobile download failures rather than as silence.
What works on mobile today
For users on iOS or Android right now, both paths are live. Web Speech via the OS voices remains the zero-cost default: it works reliably, has near-zero latency, allocates too little to trigger any reclaim heuristic, and doesn't depend on threading at all (the OS synthesises in a separate process). Piper sits alongside it as the opt-up, and on our own traffic mobile users take it — Piper is the most-selected engine on iOS, ahead of the Web Speech default.
The voice quality varies by OS — recent Apple voices like Ava and Tom are very good, Microsoft's Edge Online voices are excellent on Windows, Android's Google TTS is utility-grade — and that variability is part of why the neural option matters on mobile: Piper sounds the same on every device, because the model ships with the page rather than with the OS. Quick TTS' Android voice deep-link to system accessibility settings is the next-best UX: rather than pretend to control voice selection from inside a tab, hand the user off to the OS where the picker actually does something.
If you are building this yourself
If you are shipping WASM inference to iOS in your own product and our mitigations are not enough for your model size, the remaining options, in order of how much we'd recommend each:
- Native app wrapper. If your product is iOS-first and Piper quality is non-negotiable, ship a native iOS app and run Piper natively (it's already a C++ codebase; the Rhasspy team maintains a native build). You lose the "works in any browser" benefit but you sidestep all three failure modes — no AudioContext gating, no tab memory cliff, real threads. Quick TTS is a web product so this isn't an option for us, but it's a fine option for a different shape of product.
- Server-side fallback. Run Piper on a server, stream the audio to the client. Adds infrastructure cost (you're now paying for compute and bandwidth) but the client side is just an
<audio>tag, which iOS Safari has no problem with. The "free, in-browser" pitch is gone, but the audio works. - Ship a degraded experience to opt-in users only. Show Piper as an experimental option on iOS, behind a checkbox that says "may not work, may crash this tab". This is closest to what we landed on ourselves: Web Speech stays the mobile default and Piper is the opt-up, so nobody pays the memory cost without choosing it. If your model is larger than Piper's 60 MB voices, keep the warning text honest.
For the rest of us, the answer is narrower than it used to be: ship Web Speech as the mobile default, ship Piper alongside it with a low-RAM floor and a single worker, and keep the WebGPU engines (Kokoro, Supertonic HD) on the hardware that actually has an adapter. If you want the longer write-up on how Quick TTS handles all three engines together, our Web Speech vs Piper vs Kokoro post covers the architecture. The FAQ has the user-facing version of the "does this work on my phone" answer, and the guide walks through the use cases that drove these decisions. The product itself is at Quick TTS — open it on a phone and the AI engine is there too.