Debugging WebRTC on Safari and iOS WKWebView
There is no chrome://webrtc-internals on iOS, no about:webrtc, and inside a WKWebView there is not even an address bar to type one into. This guide is part of the Cross-Browser WebRTC Debugging guide, and it solves the specific problem that makes iOS the most expensive platform to fix a call on: before you can diagnose anything you have to manufacture the instrumentation yourself — tether Web Inspector over USB, learn which native configuration flags silently reject capture and playback, and ship a getStats() overlay that keeps reporting when no Mac is attached.
Context & Trade-offs
Three separate things are missing on iOS, and they fail differently. There is no stats dashboard, so nothing records the negotiation for you. There is no way to attach a debugger to a device that is not physically cabled to a Mac, so the bug that only reproduces on a train is invisible. And a WKWebView is not Safari: it inherits WebKit’s engine but almost none of Safari’s media defaults, so code that works perfectly in mobile Safari can produce a black rectangle inside your own app shell.
Attaching Web Inspector is the first hurdle. On the device, enable Settings → Apps → Safari → Advanced → Web Inspector; on the Mac, turn on the Develop menu in Safari’s Advanced settings; then cable the device and pick it from Develop → device name. For a WKWebView there is a further gate: since iOS 16.4 a web view is only inspectable if the host app sets isInspectable = true on the instance. Apps built before that SDK stay inspectable in debug builds by legacy behaviour, which is why an app inspects fine until the day someone bumps the deployment target and the web view vanishes from the Develop menu with no error anywhere.
What the Inspector does give you is worth knowing precisely, because engineers waste hours hunting for a panel that does not exist. There is a console, a network panel that shows the signalling WebSocket frames, a Timelines recorder, and — genuinely useful here — a Media panel that reports each <video> element’s readyState, paused flag and current size. That covers the render half of the problem. It reports nothing about ICE, DTLS or RTP, so the transport half only exists if you park a reference on window.pc during development and evaluate await pc.getStats() by hand in the console, one snapshot at a time, with no history between snapshots.
Even when the tether works, it constrains what you can observe. A cabled device is on Wi‑Fi or on a USB-shared connection, so you cannot reproduce the cellular handover failures that dominate real iOS bug reports — those need the on-device overlay described below, and the state-transition reasoning in Handling Wi-Fi to Cellular Network Handover. The Simulator is worse than useless for media: it has no camera, so getUserMedia either fails or hands you a synthetic feed that never exercises the H.264 hardware encoder. Budget for physical devices.
The second class of problem is configuration that lives outside JavaScript entirely. A WKWebView needs a secure context (https: or localhost); content served from file: or from a custom scheme registered with WKURLSchemeHandler is not a secure origin, and navigator.mediaDevices is simply undefined there — not an error you can catch, an object that does not exist. Capture also requires NSCameraUsageDescription and NSMicrophoneUsageDescription in Info.plist, and from iOS 15 the host app may implement the WKUIDelegate media-capture callback. A delegate that is implemented but never invokes its decisionHandler leaves the getUserMedia promise pending forever: no rejection, no prompt, no console output. That single mistake accounts for a large share of “the app just hangs on Join” tickets.
Playback is the third trap. Safari’s inline-playback default does not carry over: a WKWebView created with stock configuration takes video fullscreen and refuses programmatic play(), so framesDecoded climbs while the user stares at nothing. Audio has its own layer. WebKit hands WebRTC audio to AVAudioSession, and the moment a microphone track goes live the session moves to playAndRecord, whose default output on iPhone is the receiver — the earpiece — at a fraction of speaker volume. Users report it as “no sound”. The fix is native (defaultToSpeaker, mode voiceChat), and the same session settings decide which acoustic echo canceller you get, which is the device-side half of Audio Focus & Echo Cancellation Across Devices.
; Info.plist — missing either key kills capture before any prompt is drawn
NSCameraUsageDescription = Video calls need the camera
NSMicrophoneUsageDescription = Video calls need the microphone
; WKWebViewConfiguration — Safari's defaults are NOT WKWebView's defaults
allowsInlineMediaPlayback = true ; false sends remote video fullscreen
mediaTypesRequiringUserActionForPlayback = none ; the .all default rejects play()
allowsAirPlayForMediaPlayback = false ; AirPlay masks local render bugs
; WKWebView instance — required from iOS 16.4 or Web Inspector cannot see the view
isInspectable = true ; gate behind a debug build flag, never ship enabled
; AVAudioSession (host app, configured before the first audio track goes live)
category = playAndRecord ; WebKit forces this anyway once the mic opens
mode = voiceChat ; selects the platform echo-cancellation path
options = defaultToSpeaker ; without it iPhone output stays on the earpiece
Minimal Runnable Implementation
Since the platform gives you no dashboard, ship one. The overlay below polls getStats() on the same 1-second cadence the desktop dashboards use, keeps a 60-sample rolling window, and prints the six values that resolve nearly every iOS report. Crucially it also prints the elapsed time between samples, because a backgrounded WKWebView throttles timers and a 1-second interval quietly becomes 5–10 seconds — without that number every rate you compute is wrong.
// On-device HUD for iOS builds. Attach behind a ?diag=1 flag, never by default.
function attachIosDiagnosticOverlay(pc, { intervalMs = 1000, keep = 60 } = {}) {
const hud = document.createElement('pre');
// position:fixed survives WebKit's fullscreen video takeover; pointer-events:none
// stops the HUD swallowing the tap your play() call needs as its user gesture.
hud.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483647;margin:0;' +
'padding:6px 8px;font:11px/1.35 ui-monospace,monospace;color:#5ee8a0;' +
'background:rgba(0,0,0,.72);pointer-events:none;white-space:pre';
document.body.appendChild(hud);
const ring = []; // rolling window of samples, newest last
let prev = null; // previous sample, needed for byte/frame deltas
const timer = setInterval(async () => {
const s = { t: Date.now(), rxBytes: 0, txBytes: 0, frames: 0,
rtt: null, dtls: '-', pair: '-' };
for (const r of (await pc.getStats()).values()) {
// WebKit fills RTT on the nominated pair only after a check succeeds, and
// populates remote-inbound-rtp earlier — read both or Safari looks dead.
if (r.type === 'candidate-pair' && r.nominated) {
s.pair = r.state;
s.rtt = r.currentRoundTripTime ?? s.rtt;
}
if (r.type === 'remote-inbound-rtp') s.rtt = s.rtt ?? r.roundTripTime;
if (r.type === 'transport') s.dtls = r.dtlsState ?? s.dtls;
if (r.type === 'inbound-rtp' && r.kind === 'video') {
s.rxBytes = r.bytesReceived ?? 0;
s.frames = r.framesDecoded ?? 0; // zero here means decode, not render
}
if (r.type === 'outbound-rtp' && r.kind === 'video') s.txBytes = r.bytesSent ?? 0;
}
// Rate against real elapsed time, not the nominal interval.
const dt = prev ? (s.t - prev.t) / 1000 : 0;
const kbps = (now, was) => (dt ? Math.round(((now - was) * 8) / dt / 1000) : 0);
s.rxKbps = prev ? kbps(s.rxBytes, prev.rxBytes) : 0;
s.txKbps = prev ? kbps(s.txBytes, prev.txBytes) : 0;
s.fps = prev && dt ? Math.round((s.frames - prev.frames) / dt) : 0;
prev = s;
ring.push(s);
if (ring.length > keep) ring.shift();
const v = document.querySelector('video#remote');
// A muted receiver track is how an AVAudioSession interruption surfaces in JS.
const audioMuted = pc.getReceivers()
.some((r) => r.track && r.track.kind === 'audio' && r.track.muted);
hud.textContent = [
`dtls ${s.dtls} pair ${s.pair} gap ${dt.toFixed(1)}s`,
`rtt ${s.rtt == null ? 'n/a' : Math.round(s.rtt * 1000) + 'ms'}`,
`rx ${s.rxKbps} kbps ${s.fps} fps decoded ${s.frames}`,
`tx ${s.txKbps} kbps`,
`vid paused=${v && v.paused} inline=${v && v.hasAttribute('playsinline')}`,
`aud rxMuted=${audioMuted}`
].join('\n');
}, intervalMs);
// Triple-tap anywhere to copy the window as JSON — the only export path when the
// device is not tethered. touchend counts as the user gesture the clipboard needs.
let taps = 0, tapTimer = null;
document.addEventListener('touchend', () => {
taps += 1;
clearTimeout(tapTimer);
tapTimer = setTimeout(() => { taps = 0; }, 600);
if (taps < 3) return;
taps = 0;
const dump = JSON.stringify(ring);
if (navigator.clipboard) navigator.clipboard.writeText(dump).catch(() => {});
console.log('[iosdiag]', dump); // visible in Web Inspector if it is attached
}, { passive: true });
return () => { clearInterval(timer); hud.remove(); }; // call on hangup
}
Two details make this overlay survive contact with real WebKit builds. First, never assert a field exists: WebKit populates framesPerSecond and qualityLimitationReason inconsistently across releases, so derive frame rate from the framesDecoded delta rather than reading a field that may be undefined on the device in your hand. Second, keep the HUD to six lines. A 60-sample window at one sample per second is 60 seconds of history — long enough to span a screen lock or a network switch, small enough that the JSON pastes into a bug report without truncation, and cheap enough that the poll itself costs well under a millisecond of main-thread time per tick.
Reproduction Steps & Debugging Log Patterns
- Build with
isInspectable = trueand the overlay gated behind?diag=1, serve over HTTPS with a certificate the device trusts, and confirm the web view appears under Develop → device before you start the call. If it does not appear, the flag or the deployment target is wrong — stop and fix that first. - Start the call with the overlay visible and note the first sample where
dtlsreadsconnected. On a relayed path expect the extra 20–40 ms of one-way latency to show up in therttline immediately. - Force the interesting failures on the device rather than in the debugger: lock the screen for 15 seconds, background the app, then take an incoming phone call. Each one produces a distinct signature in the overlay.
- Triple-tap to copy the ring buffer, paste it into the Web Inspector console (or a notes app if you are untethered), and diff it against a Chrome trace captured with the technique in Reading chrome://webrtc-internals Dumps.
A healthy iOS session, then the three classic degradations:
// dtls connected pair succeeded gap 1.0s <- steady state
// rx 1180 kbps 24 fps decoded 612 <- decode and render agree
// dtls connected pair succeeded gap 8.4s <- app was backgrounded
// rx 0 kbps 0 fps decoded 612 <- timer throttled, not a stall
// vid paused=true inline=false <- autoplay/inline misconfig
// rx 1140 kbps 25 fps decoded 1503 <- frames arrive, nothing renders
// aud rxMuted=true <- AVAudioSession interruption
Each of those signatures has a different owner. A gap of several seconds with flat counters is the operating system throttling your timer, not a media stall — confirm it by checking that decoded resumes climbing from its old value rather than restarting. A muted receiver track that never unmutes after a phone call means the host app is not handling the audio-session interruption notification and re-activating the session; JavaScript cannot fix that, and no amount of ICE restarting will bring the audio back.
The pattern to internalise: decoded still climbing while the screen is blank is never a network problem. Rising framesDecoded with paused=true means playback policy; rising framesDecoded with paused=false and inline=false means the web view is fighting you for the fullscreen presentation. Only a flat decoded counter with dtls short of connected sends you back to transport, where the ICE reasoning in Diagnosing ICE Failures with Firefox about:webrtc transfers directly even though the tool does not.
Common Implementation Mistakes
- Testing capture in the Simulator. There is no camera, so
getUserMedianever exercises the hardware H.264 encoder and never surfaces the thermal or permission behaviour you ship into. Device-only for media. - Assuming Safari’s defaults inside a
WKWebView. Inline playback is off, autoplay requires a gesture, and the mic prompt is mediated by yourWKUIDelegate. A delegate that forgets to calldecisionHandlerproduces an eternally pending promise with no diagnostic trace anywhere. - Gating on
navigator.permissions.query({ name: 'camera' }). WebKit rejects that descriptor, so the throw is read as “denied” and the app hides the Join button on a perfectly healthy device. Track permission through the capture call anddevicechangeinstead, as in Handling Device Hotplug & Permission Changes. - Computing rates from the nominal interval. Multiplying by a hardcoded 1 second after the web view has been throttled to 8 makes an idle call look like a bandwidth collapse. Always divide by measured elapsed time.
- Blaming the network for a black frame. If
framesDecodedis rising, the transport did its job. Checkpaused,playsinline, and the encoder profile — see Forcing H.264 Hardware Acceleration on Safari. - Leaving the overlay’s
setIntervalrunning after hangup. The closure keeps theRTCPeerConnectionalive, and on iOS that keeps the camera indicator lit — a fast route to a rejected App Store review.
FAQ
Can I get a webrtc-internals-style graph out of an iOS device?
Not from the platform. Capture the ring buffer described above at 1-second resolution, export it as JSON, and plot it off-device; the sample series contains the same candidate-pair, transport, and inbound-rtp fields the Chrome dump graphs, so the same reading technique applies. For interpreting the loss and bitrate columns, Interpreting getStats() for Congestion Signals covers the arithmetic.
Why does my web view stop appearing in the Develop menu after an SDK upgrade?
iOS 16.4 made inspectability opt-in per WKWebView. Set isInspectable = true on the instance behind a debug flag. Nothing logs when it is false; the view simply is not listed.
The call has audio in mobile Safari but not in our app. Same code — why?
Because the audio path is not the same code. Safari configures AVAudioSession for you; your app owns it. With playAndRecord and no defaultToSpeaker, output goes to the earpiece, so the audio is playing and is nearly inaudible unless the phone is held to the ear.
Related: this deep-dive sits under Cross-Browser WebRTC Debugging; when the tree above points at transport continue with Disconnected vs Failed ICE States, and when an interruption leaves track.muted stuck read Muting Tracks vs Stopping Them.