WebRTC on Mobile Devices: iOS and Android in the Real World

A WebRTC call that runs flawlessly on a laptop will surprise you the first time you put it on a phone in someone's pocket. The browser tab gets suspended when the user switches apps. The microphone gets yanked away by an incoming GSM call. The battery drops a percent a minute. The connection drops every time the user walks out of Wi-Fi range and onto LTE. None of these are bugs in WebRTC — they are the operating system asserting control over scarce resources, and mobile is where you finally have to negotiate with it.

This article is about the parts of mobile WebRTC that no amount of desktop testing will surface: the choice between the mobile browser and a native SDK, the platform-specific call frameworks on iOS and Android, hardware codec availability, network handover, and the battery math that quietly governs all of it.

Two paths: mobile web vs native SDK

Mobile web versus native SDK for WebRTC Mobile web ships fastest but is limited by the platform browser, has weak background support, and no CallKit integration. A native SDK is more work but gives full control, background audio, hardware codec access, and OS call integration. Mobile web browser / WebView ✓ fastest to ship, no install ✓ one codebase ✗ iOS = WebKit only ✗ weak background, no CallKit Native SDK libwebrtc / platform SDK ✓ background audio & calls ✓ CallKit / ConnectionService ✓ hardware codecs, battery control ✗ more code, per-platform work
Figure 1 — The core mobile trade-off. Choose mobile web for speed and reach; choose a native SDK when background calling, OS integration, or battery efficiency actually matter.

Every mobile WebRTC project starts with one decision that constrains everything afterward: do you ship a web experience inside the mobile browser, or do you embed a native build of the engine?

Mobile web means RTCPeerConnection running in Safari on iOS or Chrome on Android. The appeal is obvious — one codebase, no app store review, instant updates, a link you can text to anyone. The ceiling is just as obvious. A browser tab cannot hold the microphone open while the user reads an email, cannot present a native incoming-call screen, cannot wake the device from a push, and cannot keep media flowing once the OS decides the tab is no longer foreground. For click-to-call support widgets, one-off interviews, or "join from any device" fallbacks, mobile web is the right tool. For a product whose core job is calling, it is a fallback, not a foundation.

Native SDKs embed Google's libwebrtc — the same engine inside Chrome — compiled as WebRTC.framework for iOS and an .aar for Android. You can build it from the upstream source, but most teams reach for a wrapper. react-native-webrtc exposes the peer connection API to React Native, flutter_webrtc does the same for Flutter, and vendor SDKs (LiveKit, Daily, Twilio, Agora) wrap libwebrtc with their own signaling and a higher-level call abstraction. Going native buys you background audio, system call UI, push-to-wake, and direct access to audio routing and hardware encoders. It costs you app-store distribution, larger binaries (libwebrtc adds tens of megabytes), and the obligation to track upstream branch updates yourself.

A pragmatic rule: if the call has to survive the user locking the screen or switching apps, you need native. If it does not, mobile web is cheaper and faster to ship.

iOS: WebKit, audio sessions, and CallKit

The WebKit-only legacy

For most of iOS history, every browser on the platform was Safari with a different icon — Apple required all browsers to use the WebKit engine, so "Chrome on iOS" and "Firefox on iOS" ran the same WebRTC implementation Safari did. The EU's Digital Markets Act has begun to loosen this, and alternative engines are appearing in some regions, but you should still assume that on iOS your web code targets WebKit's behavior. That means testing in Safari first, watching for the codec and autoplay quirks WebKit carries, and never assuming a Chromium feature is present just because the icon is blue.

getUserMedia dies on backgrounding

In the mobile browser, the moment the user leaves your tab, iOS stops delivering camera and microphone frames. Tracks go silent rather than throwing, so design for it: listen for visibilitychange, flip to an audio-only or paused state, and renegotiate when the page returns. There is no web API that keeps capture alive in the background on iOS — if you need that, you need a native app and CallKit.

AVAudioSession: the part that breaks demos

Native iOS audio runs through AVAudioSession, and getting its category and routing wrong is the single most common source of "why is the audio coming out of the earpiece" and "why did my call go mute after a phone call interrupted it" bugs. You generally want the playAndRecord category with voiceChat mode, and you must explicitly route to the speaker when the user is not holding the phone to their ear.

let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord,
                        mode: .voiceChat,
                        options: [.allowBluetooth, .defaultToSpeaker])
try session.setActive(true)

// Handle interruptions (incoming GSM call, Siri, alarms)
NotificationCenter.default.addObserver(
  forName: AVAudioSession.interruptionNotification,
  object: nil, queue: .main) { note in
    guard let info = note.userInfo,
          let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
          let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
    if type == .began { pauseCall() }      // OS took the audio hardware
    else if type == .ended { resumeCall() } // we can take it back
}

Interruptions are not optional to handle. A real incoming cellular call will deactivate your session, and if you do not resume cleanly afterward, the user comes back to a dead call. With a vendor SDK this is often managed for you; with raw libwebrtc it is your code.

CallKit and PushKit

To present a real incoming-call screen — full-screen, ringing on the lock screen, showing in Recents — you integrate CallKit. CallKit also gives you correct interaction with the system: the OS treats your VoIP call like a phone call, so a GSM call can hold yours and CarPlay and the hardware mute button work as users expect.

The catch is delivery. To receive a call when your app is not running, you use a VoIP push through PushKit, and modern iOS enforces a hard rule: every PushKit push must report a new incoming call to CallKit, on the same run loop, or the OS will start throttling and eventually killing your pushes. You cannot use a VoIP push to silently wake the app for other work. Plan your signaling so the push payload carries enough to call CXProvider.reportNewIncomingCall immediately, then connect media afterward.

Android: Telecom, foreground services, and fragmentation

ConnectionService and the Telecom framework

Android's analog to CallKit is the Telecom framework. By implementing a ConnectionService and registering a PhoneAccount, your VoIP calls participate in the system call experience: they show in the call log, interoperate with cellular calls, and can route audio through the in-call UI. As on iOS, a vendor SDK may abstract this; on a raw build you wire it yourself.

Foreground services are mandatory now

To keep a call alive while the app is backgrounded, Android requires a foreground service with a persistent notification — the OS will not let you hold the microphone and keep CPU running otherwise. Recent Android versions tightened this considerably: you must declare a typed foreground service and request the matching permission. For a call, that is the microphone (and camera) service type.

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<service
    android:name=".CallService"
    android:foregroundServiceType="microphone|camera"
    android:exported="false" />

The broader trend is that Android keeps narrowing what apps can do in the background — Doze, App Standby buckets, and ever-stricter limits on starting services from the background. The reliable pattern is: a high priority FCM data message wakes the app, you immediately start the foreground service and (ideally) report the call to Telecom, and only then negotiate media.

Audio focus

Android arbitrates the speaker through audio focus. Request AUDIOFOCUS_GAIN_TRANSIENT for the duration of the call so music and other apps duck or pause, and abandon focus when the call ends. Ignoring focus produces the Android equivalent of the iOS routing bugs: a call that fights with Spotify, or audio that comes back wrong after a notification sound.

Fragmentation is the tax

The hard part of Android is not the framework — it is the device matrix. Camera orientation, available capture resolutions, front/back behavior, hardware-encoder bugs, and echo-cancellation quality all vary by chipset and OEM. A capture profile that is crisp on a flagship Pixel may be a stuttering mess on a budget MediaTek device. Maintain a tested device list, lean on the OEM-agnostic paths in your SDK, and keep conservative capture defaults so the long tail of cheap hardware does not melt.

Hardware codecs: where codec choice becomes battery life

On desktop, video encoding is "free" enough that codec choice is mostly about quality and compatibility. On a phone it is the difference between a comfortable call and a hot, draining one, because the deciding factor is whether the codec runs on dedicated silicon or on the CPU.

  • H.264: hardware encode and decode are near-universal on mobile, because the same blocks power the device camera app. Choosing H.264 keeps video off the CPU on the widest range of phones, which is why many mobile-first apps still default to it.
  • VP8: broadly supported in the engine, but on many phones it falls back to software encode. Software VP8 at 720p can pin a core and is one of the most common hidden battery drains in mobile WebRTC.
  • VP9 / AV1: hardware decode is growing fast on newer SoCs; hardware encode is far rarer. Decoding an incoming AV1 stream may be cheap on a recent device while encoding one in software would cook it.

The takeaway is asymmetry: a phone can often decode a modern codec in hardware while still needing to send in H.264 to stay on the encoder silicon. If you control both ends, negotiate per-direction with the device's real hardware support rather than assuming symmetry. For a deeper comparison of the codecs themselves, see our codec selection guide.

Thermals add a second dimension. Sustained encode heats the SoC, the OS throttles the clock, and the encoder starts dropping frames or resolution. Use RTCRtpSender.setParameters() with a sensible degradationPreference so the engine degrades gracefully — typically "maintain-framerate" for talking-head video, trading resolution to keep motion smooth.

The mobile network: handover, CGNAT, and TURN

Mobile devices change networks constantly. A user walks from their desk to the elevator and the call hops from Wi-Fi to LTE; the local IP changes, the old candidate pair dies, and without intervention the call freezes. The fix is an ICE restart: detect the disconnection and call restartIce() to gather fresh candidates over the new interface.

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'disconnected' ||
      pc.iceConnectionState === 'failed') {
    // Network likely changed (Wi-Fi to cellular). Re-gather candidates.
    pc.restartIce();
    // Then create a new offer with iceRestart and renegotiate.
  }
};

The bigger structural difference is NAT. Carrier networks run aggressive carrier-grade NAT (CGNAT), sharing one public address across many subscribers, and two phones on cellular often cannot reach each other directly even with STUN. The practical consequence: TURN relaying is needed far more often on mobile than on desktop. Where a desktop-heavy product might relay 10–15% of connections, a cellular-heavy mobile audience can push that meaningfully higher. Budget for it — and if relay traffic becomes your dominant cost, our TURN cost optimization guide covers how to keep it in check.

One counterintuitive note: cellular carriers rarely block UDP, so STUN and TURN-over-UDP usually work fine on LTE/5G. The networks that break WebRTC are restrictive corporate and guest Wi-Fi, where UDP is firewalled and you fall back to TURN over TCP/TLS on 443. Because mobile users roam across both worlds, your ICE configuration has to cover both. The cheapest way to find out whether your STUN and TURN servers are actually reachable from a given network is to test them directly — that is exactly what the ICE Server Tester is for; run it from the phone's browser on the same Wi-Fi the users complain about.

Battery and CPU budgets

The number one drain in a mobile call is video encode, followed by the radio and the screen. You cannot turn off the screen during an active video call, but you have real control over the encoder:

  • Cap capture resolution and framerate. Sending 360p at 24 fps instead of 720p at 30 fps dramatically cuts encoder load, and on a small phone screen the remote viewer often cannot tell.
  • Set degradationPreference. Let the engine shed quality under thermal or bandwidth pressure instead of dropping the call.
  • Kill video on background. When the app is backgrounded, stop the camera and send audio only. This is also exactly the behavior iOS forces on web capture, so it doubles as correctness.
  • Offer an audio-only mode. The biggest battery win available is letting users drop video entirely on a long call. Make it one tap.

Prefer hardware H.264, keep the encoder fed conservatively, and the difference between a 30-minute call and a 90-minute one is mostly these choices.

Permissions UX

Mobile permission prompts are heavier than the desktop's quiet address-bar dot, and timing matters. Ask for camera and microphone access in context — when the user taps "start call," not on app launch — because a denied prompt is sticky and recovering from it means sending the user into system Settings. On both platforms users can revoke camera or microphone per-app at any time, and iOS surfaces the access in its privacy indicators, so handle a mid-session revocation as a real state, not an impossible one. Detect the denied getUserMedia (or native equivalent) and show a clear path to Settings rather than a spinner that never resolves.

Quick reference: concern by platform

Concern iOS approach Android approach
System call UI CallKit (CXProvider) Telecom (ConnectionService + PhoneAccount)
Wake to receive a call PushKit VoIP push, must report to CallKit High-priority FCM data message, start foreground service
Background call survival CallKit + audio session keeps it alive Typed foreground service + persistent notification
Audio routing AVAudioSession (playAndRecord / voiceChat) Audio focus + AudioManager mode
Interruptions (GSM call) AVAudioSession.interruptionNotification Telecom hold + audio focus loss callbacks
Mobile web capture limit Capture stops on background (WebKit) Capture stops on background; PWA limited too
Preferred send codec Hardware H.264 Hardware H.264 (verify per device)
Network handover restartIce() on disconnect restartIce() on disconnect

Check Your Own Servers

Mobile users roam between CGNAT cellular and locked-down corporate Wi-Fi, so a STUN or TURN server that works at your desk can be unreachable from the field. Open the tester in the phone browser on the exact network your users complain about and confirm your ICE servers actually respond.

Open the Free ICE Server Tester →