← Back to Wiki
Mobile / WebRTC

A Backgrounded WKWebView Kills Your Audio After 27 Seconds, and No Entitlement Fixes It

Wrapping an existing web app in a WebView is a genuinely reasonable way to ship a mobile client. You get one codebase and one set of bugs. It holds up for chat, for settings, for everything visual. It stops holding up for voice, and it stops for a reason no amount of configuration will change.

Share on X

The two platforms are not equivalent here

BE WARNED: locking the screen is the normal way to use a voice app. That is what makes this disqualifying rather than annoying. Nobody holds a phone with the screen on for a half-hour call. A foreground-only voice feature will be reported as "the app randomly disconnects", and every one of those reports will be correct.

Decide before you build, not after

The tempting plan is to ship the WebView version now and add native voice later. That works only if you are honest that the first release has no working voice, because "voice that dies when you lock the screen" is a feature you will spend more time explaining than it saves.

We scoped background-capable voice as in from day one, on the basis that it was the entire point of the app on a phone. The rest of the client stayed a WebView wrap.

Native for voice, WebView for everything else

The split that works is narrow. One native plugin wrapping the platform's real WebRTC SDK, and every other screen unchanged. Put an interface in front of it and pick at runtime:

// one interface, two implementations
interface VoiceEngine { connect(); disconnect(); setMuted(b); }

export const engine = Capacitor.getPlatform() === 'ios'
  ? new NativeVoiceEngine()   // native SDK over a plugin bridge
  : new WebVoiceEngine()      // today's browser code, unchanged

Extract the existing implementation mechanically, with no behaviour changes, and re-verify it against two real accounts before you write a line of the native one. Otherwise a native-voice bug and a refactor bug are indistinguishable for the rest of the project.

Do not let an iOS-only package become everyone's build dependency

A real correction made mid-build, and worth stealing. The native plugin was going to expose its own JavaScript side as an npm dependency of the web app. That would have given every consumer of that build step a dependency on an iOS-only package, including the Docker image, the desktop build and Android.

Caught by checking which other workflows build the same directory. The fix was to strip the plugin to native code only and register it from the web side by string name, so there is no cross-package build dependency at all:

const Voice = registerPlugin('LiveKitVoice')   // matched by name, not by import

Before adding a dependency to a shared build, grep your CI for everything else that runs it.

Accept the capability differences instead of hiding them

The native path will not have feature parity on day one, and pretending otherwise produces worse bugs than admitting it:

You do not need a Mac to find out it compiles

Hosted macOS runners build and sign this without local Apple hardware. Our only genuine compile error, a property that is optional in the real SDK and not in the docs, was found by CI rather than by a developer machine, because there was no developer machine that could build it.

A local Mac is for interactive debugging. It is not required to know whether your native plugin compiles against the real dependency, and that is the feedback that matters early.

When this isn't your problem