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.

Diagnostic surface available in each Apple WebRTC runtime A six-row matrix comparing Safari on macOS, Safari on iOS, an in-app WKWebView, and SFSafariViewController across stats dashboard, Web Inspector attachment, getUserMedia support, inline playback, audio routing, and getStats availability. Diagnostic surface by Apple runtime capability Safari macOS Safari iOS WKWebView SFSafariVC stats dashboard none none none none Web Inspector built in USB tether USB + flag USB tether getUserMedia yes yes iOS 14.3+ no control inline playback default playsinline config flag playsinline audio routing system system AVAudioSession system getStats() yes yes yes yes
Only the bottom row is portable — every Apple runtime answers getStats(), and nothing else is guaranteed.

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.

Which iOS layer produces which media symptom Five stacked layers from page JavaScript down through WKWebView configuration, the WebKit media engine, iOS privacy permissions, and AVAudioSession, each paired with the user-visible symptom it produces when misconfigured. Where an iOS media failure actually lives layer you must change symptom you observe Page JavaScript getUserMedia() and video.play() NotAllowedError in the console WKWebView configuration allowsInlineMediaPlayback = true remote video jumps fullscreen WebKit media engine H.264 decode, playsinline honoured frames decode, canvas stays black iOS privacy layer Info.plist usage strings, WKUIDelegate no prompt, promise never settles AVAudioSession playAndRecord, voiceChat, speaker audio stuck on the earpiece
Only the top layer is reachable from JavaScript; the other four are native settings that fail silently.

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
}
Data path of the on-device getStats overlay A one-second timer drives getStats, samples are normalised into rates using the previous sample, stored in a sixty-entry ring buffer, and rendered to a fixed HUD element while a triple-tap exports the buffer as JSON. Overlay data path with no debugger attached 1 s timer setInterval pc.getStats() RTCStatsReport normalise bytes to kbps ring buffer 60 samples HUD element 6 live lines JSON export triple-tap previous sample supplies the delta oldest sample newest a throttled background tab leaves visible gaps in this strip
The ring buffer is the whole point: the failure is over before you can tether the device.

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

  1. Build with isInspectable = true and 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.
  2. Start the call with the overlay visible and note the first sample where dtls reads connected. On a relayed path expect the extra 20–40 ms of one-way latency to show up in the rtt line immediately.
  3. 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.
  4. 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.

Triage tree for a silent or black iOS call A branching decision tree starting from whether framesDecoded is increasing, splitting on whether the video element is paused and whether dtlsState is connected, ending in four outcomes: autoplay blocked, inline playback disabled, transport fault, and codec mismatch. Triage tree: fine in Chrome, nothing on the iPhone framesDecoded increasing? yes no video element paused? dtlsState connected? yes no no yes autoplay blocked muted + tap to play() inline playback off playsinline + config transport fault check ICE and TURN codec mismatch H.264 profile vs VP8 audio-only symptom? none of these branches apply check AVAudioSession category and route before touching any JavaScript
Four questions separate a WebKit playback-policy bug from a genuine transport failure.

Common Implementation Mistakes

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.