What the specifications say and what the browsers actually do are two different documents. These 165 divergences are the ones that cost production teams their weekends — browser asymmetries, silent failures, and the sharp edges that only appear on real networks.
Filter by tag
Every quirk carries a tag (api: 58, browser: 29, deployment: 30, perf: 25, security: 6, spec: 17). The filter itself requires JavaScript; all 165 quirks are listed below in course order.
G.114 budgets one-way mouth-to-ear delay, but the only latency number WebRTC hands you is RTCRemoteInboundRtpStreamStats.roundTripTime, and that covers the network path only. Halving RTT omits capture buffering, encode, the jitter buffer, decode, and render — on a laptop that is another 60–150 ms, and on a Bluetooth headset add 100–300 ms more that no API reports. Teams routinely certify a call as "80 ms, well inside G.114" when the real mouth-to-ear figure is over 300 ms.
Build the budget explicitly: roundTripTime/2 + jitterBufferDelay/jitterBufferEmittedCount + totalDecodeTime/framesDecoded + a measured constant for device I/O. Chrome also exposes jitterBufferTargetDelay and totalProcessingDelay, which capture most of the receive-side pipeline. For headsets, measure once with a loopback tone and a phone recorder; there is no programmatic answer.
An ACR MOS is the arithmetic mean of scores from a human listening panel. Every "MOS" on every dashboard is an estimator — usually the ITU-T G.107 E-model R-factor mapped to a 1–5 scale, or a vendor regression. The E-model was calibrated on narrowband codecs with 1990s packet-loss concealment; feed it Opus at 48 kHz with DTX and NetEq time-stretching and it returns values above 4.5 or below 1.0. Two vendors will report different MOS for the identical call, and neither is wrong.
Report the inputs, not the scalar: loss rate, concealedSamples/totalSamplesReceived, jitter buffer delay, and codec. If leadership insists on one number, publish which estimator and version produced it, and never compare across vendors or across your own releases without re-baselining.
Erlang-B assumes a fixed DS0 per call and blocked-calls-cleared: when the trunk group is full you get a busy signal and existing calls are untouched. Packet media has no admission control anywhere in the path, so overload does not block the 101st call — it degrades all 100 that were already fine. An SFU at 95 % CPU does not refuse work; it adds encode latency to every participant simultaneously, which is why capacity incidents present as "everyone's audio got choppy at once" rather than "new calls failed".
Implement the busy signal yourself. Cap publishers per SFU node, cap total forwarded bitrate, and reject or redirect at the signaling layer on CPU, packet-rate, and port-count thresholds. Set the threshold at the knee (typically 60–70 % CPU for an SFU), not at saturation, because the degradation is not graceful.
new AudioContext({ sampleRate: 16000 }) is honoured by Chrome and Firefox but was long ignored or thrown on by Safari, and it has no effect on the capture rate anyway — WebRTC resamples internally. On macOS the hardware may run at 44,100 Hz while the getUserMedia track reports 48,000. On many Android devices the capture path is locked to 16 kHz when echoCancellation: true selects the hardware AEC, so your "fullband Opus" call is upsampled narrowband and no stat tells you.
Read audioContext.sampleRate after construction and never hard-code 48000 in your DSP. On Chrome, track.getSettings().sampleRate reveals the actual capture rate; if it reads 16000 on Android and you need wideband, try echoCancellation: false and accept that you now own echo control.
Web Audio and AudioWorklet hand you Float32Array nominally in [-1, 1], but nothing clamps it: a gain node, a sum of sources, or an over-driven mic will hand you 1.4. The textbook conversion Math.round(x * 32767) then overflows the int16 range, and if you write into an Int16Array the value wraps from +32767 to a large negative number. The audible result is a loud click on every peak, which sounds exactly like a network glitch and gets debugged as one for days.
Clamp before scaling: Math.max(-1, Math.min(1, x)), then multiply by 32767 for positives and 32768 for negatives (the range is asymmetric). If you are round-tripping to int16 and back, expect to see a noise floor around -90 dBFS; that is quantisation, not a bug.
G.711 appears in WebRTC as PCMU (payload type 0) and PCMA (payload type 8) — static assignments from RFC 3551, still negotiated because PSTN gateways want them. Their RTP clock rate is 8000, so a 20 ms frame advances the timestamp by 160, not by 960 as Opus does at 48 kHz. Any code that converts RTP timestamps to wall time with a hard-coded divisor produces a 6x error the moment a call falls back to G.711, and the fallback happens silently when a gateway is in the path.
Always read the clock rate from the negotiated codec: getStats() exposes it as RTCCodecStats.clockRate, joined via inbound-rtp.codecId. Note that video is always 90000 regardless of frame rate, and Opus always declares 48000 regardless of the actual audio bandwidth.
A 20 ms G.711 frame is 160 bytes of payload. Add 12 bytes of RTP header, 8 of UDP, 20 of IPv4, and a 10-byte SRTP authentication tag and you are sending 210 bytes 50 times per second — 84 kbit/s, or roughly 96 kbit/s over a TURN relay with the ChannelData framing and Ethernet headers. Capacity plans built from codec bitrates under-provision by 30–50 % for audio, and the error is worst exactly where bandwidth is scarce.
Plan from wire bitrate, and in getStats() remember that bytesSent is payload only — headerBytesSent is a separate counter you must add. The transport stats object gives you the honest figure including STUN, DTLS, and RTCP.
Depending on your user base, 5–10 % of sessions run on networks where outbound UDP to arbitrary ports is dropped: corporate egress filters, hotel and conference Wi-Fi, some airline and cruise links, and a surprising number of "guest" SSIDs. Many of these allow UDP 3478 but not UDP 443; some allow UDP only after an established TCP session to the same host; some permit small UDP datagrams and silently drop anything over ~1000 bytes, which kills video keyframes while leaving audio perfect. Because ICE falls back rather than failing, this shows up as bad quality and mysterious bills, not as errors.
Always configure a TURN server reachable at turns: on TCP 443 with real TLS, not just turn: over UDP. Then instrument it: read candidate-pair.selectedCandidatePairId, look up the local candidate, and record candidateType and relayProtocol for every session. A relay rate above a few percent, or any TCP relay at all, is a network story you need to be able to tell.
UDP removes in-order delivery, but a=group:BUNDLE puts audio, video, and the data channel on a single 5-tuple sharing one congestion controller and one router queue. A video keyframe burst therefore queues ahead of the audio packets behind it in the same bottleneck buffer, so audio jitter correlates with video bitrate in a way it did not in the pre-BUNDLE era. This is a deliberate trade — one NAT mapping, one DTLS handshake, one estimator — but it means you cannot isolate audio from video congestion.
Set networkPriority: 'high' on the audio sender's encoding via setParameters; Chrome maps it to DSCP EF and to internal pacer priority. Expect the DSCP marking to be stripped at the first ISP boundary, so the pacer priority is the part that actually helps. Unbundling is not a practical option: browsers require BUNDLE in modern configurations.
When ICE lands on a TCP relay, every RTP packet for every stream travels inside one TCP byte stream. A single loss stalls delivery of everything behind it until the retransmission arrives, and by then the audio jitter buffer has already concealed the gap and will discard the late packet as useless. The result is worse than the original loss: you pay the retransmission bandwidth, get the stall, and still hear the artifact. On a lossy mobile link a TCP relay can be dramatically worse than plain lossy UDP.
Detect it (relayProtocol === 'tcp' on the selected local candidate) and adapt: lower the video target with setParameters maxBitrate, consider dropping to audio-only, and surface a "limited network" indicator. Do not treat a TCP relay as a normal path with a bit more latency.
libwebrtc caps RTP packets around 1200 bytes for a reason: PPPoE takes 8 bytes, common VPNs take 60–100, IPv6 guarantees only 1280, and a fragmented UDP datagram is lost entirely if any fragment is lost. Most WebRTC traffic is safe by construction, but the moment you add a large RTP header extension, a TURN ChannelData wrapper, and an SRTP tag on a 1400-byte VPN link, some packets fragment and your loss rate jumps in a way that correlates with frame size rather than with network conditions. The classic signature is "video fails only for users on the corporate VPN".
Do not raise the packet size. If you are writing a native endpoint or SFU, keep the RTP payload at or below 1200 bytes and account for TURN framing. When debugging a single-user failure, ask for their MTU (ping -s with the don't-fragment bit) before anything else.
Each SSRC picks a random 32-bit starting timestamp and advances it in its own clock units — 48000 Hz for Opus, always 90000 Hz for video regardless of frame rate. There is no arithmetic relationship between the two streams' timestamps. The only thing that binds them is the NTP-to-RTP mapping in RTCP Sender Reports, which is why A/V sync depends on RTCP arriving and why a=rtcp-rsize (reduced-size RTCP) can degrade sync if an implementation stops emitting full SRs.
If you write an SFU, forward Sender Reports and rewrite their RTP timestamp field consistently with whatever you did to the media timestamps. If you are debugging drift in a browser, check that remote-outbound-rtp exists at all — no SR means no sync reference.
At 30 fps the timestamp advances 3000 per frame, but a 40 KB keyframe is 35 packets that all carry the identical timestamp with increasing sequence numbers, the last one setting the marker bit. So timestamps are not monotonic per packet, packet count is not frame count, and any analysis that groups by timestamp must also handle the marker bit. If a sender or SFU gets the marker bit wrong, the receiver's frame assembler waits for a boundary that never comes and video freezes while packetsReceived keeps climbing.
When frames stop but packets do not, compare inbound-rtp.framesReceived against packetsReceived: a flat frame count with a rising packet count is a frame-assembly problem (marker bit, or a missing packet in every frame), not a network problem.
replaceTrack() keeps the SSRC and needs no renegotiation. removeTrack() followed by addTrack() produces a new SSRC, a new outbound-rtp stats object, and a fresh set of counters starting at zero. Dashboards that sum or difference bytesSent without keying on SSRC see the bitrate collapse to zero and then spike, which reads as a network event. ICE restarts and codec switches can also spawn new stats objects.
Key all rate computations on ssrc, and treat any negative delta as "new epoch, restart the accumulator" rather than as data. Prefer replaceTrack() over remove/add for camera switches and mute precisely because it preserves the SSRC and the receiver's decoder state.
Retransmissions travel on a separate SSRC bound to the media SSRC by a=ssrc-group:FID <media> <rtx>, with their own payload type declared by a=fmtp:<rtx-pt> apt=<media-pt>. Depending on browser version those bytes appear either folded into the media outbound-rtp as retransmittedBytesSent or as an additional stats object. Sum everything blindly and you either double-count retransmissions or miss them entirely, and your measured bitrate diverges from what the network sees by exactly the amount you care about during loss.
Subtract retransmittedBytesSent when you want original media rate, and report it separately as a loss-recovery metric. Its ratio to bytesSent is one of the better early indicators of a deteriorating path.
RFC 3550 §6.4.1 defines cumulative packets lost as a signed 24-bit value precisely so that duplicates can drive it below zero, and browsers faithfully surface negative values in getStats(). Duplicates arrive routinely: retransmissions that race the original, and some middleboxes that duplicate UDP. A loss rate computed as packetsLost / (packetsLost + packetsReceived) then goes negative, and a dashboard that clamps at zero hides the fact that something is duplicating your traffic.
Compute loss from deltas, allow negatives, and alert on them — sustained negative loss means duplication, which wastes bandwidth and confuses the bandwidth estimator. Separately, remember the RTCP fraction lost field is 8-bit fixed point, so its resolution is 1/256, or about 0.4 %.
remote-inbound-rtp is synthesised from Receiver Reports the far end sends you, so it does not exist on the first getStats() poll and typically appears 1–5 s into the call. On a send-only connection you never get remote-outbound-rtp; on a receive-only connection you never get remote-inbound-rtp and therefore never get an RTT. Code that reads stats.roundTripTime.toFixed(1) on the first poll throws a TypeError, usually inside a setInterval where nobody sees the exception.
Guard for absence, and for a smoother figure use totalRoundTripTime / roundTripTimeMeasurements rather than the instantaneous value. If you need an RTT on a receive-only path, read candidate-pair.currentRoundTripTime, which comes from STUN consent checks and updates about every 5 s.
RFC 3550 §A.8 computes interarrival jitter in RTP timestamp units; getStats() converts to seconds, so a healthy value looks like 0.004. Half the dashboards in the world multiply by 1000 twice or not at all. Worse, the algorithm is an exponentially weighted moving average with a 1/16 gain, so it takes roughly 16 packets to react and it systematically understates bursty delay — the 400 ms Wi-Fi stall that ruined the call barely moves it.
Treat jitter as a coarse trend line only. For actual delay pathology use jitterBufferDelay/jitterBufferEmittedCount (the buffer's own view, in seconds) and, for audio, concealmentEvents, which counts discrete concealment episodes and does spike on a stall.
WebRTC muxes RTCP onto the media port (RFC 8858), and browsers will not negotiate a separate RTCP port. That is a net win — one NAT mapping instead of two — but it means network teams cannot classify or prioritise RTCP separately, and legacy SBCs that offer only non-muxed RTCP simply fail to interoperate with no browser-side flag to relax it. The 5 % RTCP bandwidth cap and 5 s minimum report interval from RFC 3550 §6.2 also do not describe WebRTC, which uses a=rtcp-fb immediate feedback at far higher rates.
Require a=rtcp-mux in your gateway configuration and validate it in integration tests. When someone reports "RTCP is not being sent", check whether they are looking for a second port that no longer exists.
SDP was designed to describe a multicast session on a flyer, not to negotiate between two parties; offer/answer was retrofitted five years later in RFC 3264. That history explains the parts that make no sense: identity is positional (the m-line index is the stream's name), direction is expressed as four ad-hoc attributes with an a=inactive escape hatch, and there is no way to say "I do not support this" other than zeroing a port. Nearly every SDP bug you will ever debug is a consequence of positional identity.
Never key application state on m-line index. Use a=mid (exposed as transceiver.mid) as the stable identifier, and carry your own semantic labels out-of-band in your signaling messages rather than trying to encode them in SDP.
Chrome emits o=- <session-id> <version> IN IP4 127.0.0.1. The address is a placeholder, not the peer; the session id is a random 64-bit-ish number; the version is supposed to increment on modification. Logging the o= address as "the remote IP" is a rite of passage. Meanwhile some SIP stacks fail to increment the version on a re-INVITE, and a strict peer will treat the second offer as a duplicate and ignore it.
Get peer addresses from ICE candidate stats (local-candidate/remote-candidate joined through the selected pair), never from o= or c=. If you build SDP by hand for a gateway, increment the o= version on every modification even though browsers do not check.
RFC 4566 §5 specifies \r\n line endings. The idiomatic munge — sdp.split('\n').map(...).join('\n') — leaves the trailing \r on every untouched line and drops it on the joined ones, producing a mixed-ending document. Chrome's parser is forgiving and accepts it, which is exactly the problem: your munge appears to work in development and then a SIP gateway, a Firefox code path, or an SFU's stricter parser rejects it in production.
Split on /\r\n/ and join on '\r\n', and assert that the final string contains no bare LF. Better: do not munge. Most historical reasons to munge (codec order, bitrate caps, simulcast) now have real APIs in setCodecPreferences, setParameters, and addTransceiver.
v=0 has never changed and never will; SDP carries no version negotiation for the format itself. What varies is which attributes a given browser build writes, and the only way to learn that is to generate an offer and read it — which is why so much code calls createOffer() purely to sniff capabilities. That pattern is fragile because generating an offer has side effects on the peer connection's state machine.
Use RTCRtpSender.getCapabilities(kind) and RTCRtpReceiver.getCapabilities(kind) — both static, both side-effect-free, and both correctly report per-device differences that a hard-coded codec list cannot. Note that send and receive capabilities genuinely differ on real hardware.
You cannot hand a classic SIP endpoint's SDP to setRemoteDescription(). It will lack a=fingerprint and a=setup (so no DTLS), lack a=ice-ufrag/a=ice-pwd (so no ICE), lack a=mid and a=group:BUNDLE, use the RTP/AVP profile instead of UDP/TLS/RTP/SAVPF, and often offer a=crypto (SDES) which browsers removed years ago. Chrome will throw or, worse, accept it and then never complete a handshake.
Put a B2BUA or a media gateway in the path — Asterisk, FreeSWITCH, Kamailio with rtpengine, or a commercial SBC. Do not attempt to translate SDP in JavaScript; the media plane (SRTP keying, RTCP translation, codec transcoding) is the actual work and it cannot live in the browser.
The classic SIP hold is a=sendonly or a=inactive with the connection address zeroed. A browser has no concept of "hold": it accepts the description, then ICE has nothing to connect to and the transport eventually fails. You get a torn-down session instead of a held call, several seconds later, with no obviously related error.
Have the gateway keep a real address and express hold purely with direction attributes, or model hold entirely in your application layer (stop sending, show UI) and never let a zeroed c= reach setRemoteDescription.
H.264 in WebRTC is negotiated with a=fmtp carrying profile-level-id and packetization-mode. Mode 0 is single-NAL-per-packet: no fragmentation units, so any NAL larger than the MTU cannot be sent. Chrome will happily negotiate mode 0 with a legacy endpoint and then be unable to transmit keyframes at anything above low resolution. Video appears to work at 320x240 and fails at 720p, which looks like a bandwidth problem.
Require packetization-mode=1 in your gateway's offer and reject mode-0-only peers explicitly rather than degrading. Also check profile-level-id: 42e01f is constrained baseline level 3.1, which caps you near 720p30 — a legacy peer offering level 1.3 will silently limit resolution.
RTCDTMFSender hangs off RTCRtpSender.dtmf and only works if telephone-event (RFC 4733, usually payload type 101 or 126) was negotiated on that m-line. If it was not, dtmf is null or canInsertDTMF is false and insertDTMF() does nothing — no exception. Chrome also clamps duration to a 40–6000 ms range and interToneGap to a 30 ms minimum, so a tight loop of tones is stretched and your IVR timing assumptions break.
Check sender.dtmf?.canInsertDTMF before offering a keypad in the UI, and confirm the gateway includes telephone-event in its answer. For IVRs, send the whole string in one insertDTMF() call and let the browser pace it rather than driving tones from a timer.
a=ice-ufrag, a=ice-pwd, a=fingerprint, and a=setup are legal at session level or media level, with media level overriding. Browsers have differed on where they write them, and with BUNDLE the values are identical across m-lines anyway — until they are not. A munger that rewrites "the fingerprint" with a single non-global regex changes one occurrence and leaves the rest, producing a description that parses cleanly and then fails DTLS on some m-lines.
If you must rewrite these, rewrite every occurrence at both levels. In practice, modifying a=fingerprint, a=ice-ufrag, or a=ice-pwd is rejected by setLocalDescription in current Chrome, which is a feature — treat the rejection as a signal that your approach is wrong.
b=AS:1000 means 1 Mbit/s — the units are kilobits per second including IP overhead. RTCRtpEncodingParameters.maxBitrate is bits per second, so maxBitrate: 1000 is 1 kbit/s and produces a black smear rather than an error. Firefox honours b=TIAS (RFC 3890, bits per second) where Chrome historically honoured b=AS, so the same munge behaves differently across browsers. Opus's maxaveragebitrate fmtp parameter is also in bits per second, up to 510000.
Stop munging b= lines and use sender.setParameters() with encodings[0].maxBitrate in bits per second. Write the unit into the variable name. If you see a video stream pinned at an absurdly low quality with no qualityLimitationReason, suspect a factor-of-1000 error before suspecting the network.
In WebRTC the connection address is determined entirely by ICE. Chrome writes c=IN IP4 0.0.0.0 in an offer generated before gathering, and something that looks like a real address afterwards, and neither is authoritative. Tooling that parses c= to display "connected to 203.0.113.5" is displaying a fiction, and IP-based allowlisting driven off c= allowlists the wrong thing.
Read the selected candidate pair from getStats() for the real addresses. For allowlisting, allowlist your TURN servers and force iceTransportPolicy: 'relay' — that is the only architecture where the peer address is knowable in advance.
Chrome writes numeric MIDs (0, 1, 2) matching m-line order at first negotiation; older Firefox wrote sdparta_0 style tokens. Neither is guaranteed, and when a rejected m-line is recycled for a new track the MID changes while the index stays put. MIDs also travel on the wire in the MID RTP header extension (RFC 8843), so long MID strings cost bytes in every single packet.
Treat MIDs as opaque, never parse them as integers, and never assume mid === String(index). If you control an SFU, key on MID but re-read it after every renegotiation rather than caching it for the session.
RTCRtpTransceiver.direction is your local intent and is settable. currentDirection is the negotiated result and is null until an answer has been applied. Reading direction to decide whether media is flowing is one of the most common WebRTC bugs, because it reports sendrecv on a transceiver whose peer answered recvonly. Setting direction also fires onnegotiationneeded, so flipping it in a UI handler starts a renegotiation you may not have wanted.
Branch on currentDirection, and use replaceTrack(null) or track.enabled = false for mute instead of changing direction — neither triggers renegotiation.
Rejection is expressed as m=video 0 UDP/TLS/RTP/SAVPF ... — port zero — and JSEP (RFC 8829 §5.3.1) requires that the m-line count and order be preserved forever, by both sides. So SDP only grows. A long meeting where participants repeatedly start and stop screenshare accumulates zeroed m-lines, and a browser may recycle one for a new track, which changes that m-line's MID and a=msid while leaving its index alone.
Reuse transceivers with replaceTrack() instead of add/remove cycles. If you must free an m-line, transceiver.stop() is the only API that truly retires it, and it is comparatively recent — feature-detect it. Watch your signaling message size limits: SDP over 64 KB breaks some transports.
The payload types and their preference order that govern what a sender transmits come from the answer, not the offer. Reordering m=video 9 ... 96 98 102 in your own offer changes your receive preference and nothing else, which is why "we munged the SDP to force VP9" so often has no measurable effect. Conversely, reordering in the answer you send changes what the far end sends you.
Use transceiver.setCodecPreferences() before createOffer(); in Chrome it constrains both directions for that m-line. On recent Chrome, setParameters() with encodings[].codec selects the send codec without touching the m-line at all. Verify with getStats() by joining outbound-rtp.codecId to the codec object — never by reading your own SDP.
setLocalDescription() with no argument implicitly creates the right kind of description for the current signaling state. The older createOffer() then setLocalDescription(offer) pattern opens a window in which the ICE ufrag and candidate generation have not started, application state can change, and an interleaved remote offer produces an InvalidStateError. The argumentless form also makes rollback tractable, which is why perfect negotiation depends on it.
Write await pc.setLocalDescription() and send pc.localDescription. Feature-detect for very old Safari if you still support it. If you need to inspect the offer before sending, inspect pc.localDescription after the call, not a separately created object.
navigator.mediaDevices is only defined in a secure context. localhost and 127.0.0.1 qualify; http://192.168.1.20:5173 does not, so navigator.mediaDevices is undefined and you get TypeError: Cannot read properties of undefined rather than a NotAllowedError. Everyone hits this the first time they open their dev server on a phone, and the error message points nowhere near the cause.
Serve over HTTPS for LAN testing — a Vite --https config with mkcert, a tunnel, or Chrome's --unsafely-treat-insecure-origin-as-secure=http://192.168.1.20:5173 with --user-data-dir. Guard the call: if (!navigator.mediaDevices?.getUserMedia) throw new Error('insecure context').
createOffer() serialises the peer connection's state at the moment it is called. Add a track between createOffer() and setLocalDescription() and it is absent from the offer, but onnegotiationneeded fires again, so the track appears one round trip later — or never, if your signaling only handles one exchange. The symptom is a first call where audio works and video does not, fixed by reloading, which makes it look like a race in your own code (it is, but not where you think).
Assemble all senders before you negotiate, and treat onnegotiationneeded as the single entry point to the offer flow rather than calling createOffer from UI handlers.
Trickle ICE means candidates routinely arrive before the description they belong to, especially over a fast WebSocket. addIceCandidate() before setRemoteDescription() rejects; the spec now says implementations should queue, but relying on that across Chrome, Firefox, and Safari versions is optimistic. Because the rejection is an unhandled promise rejection inside a message handler, it is invisible, and you lose exactly the candidates that would have connected fastest.
Keep an array, push candidates while pc.remoteDescription === null, flush after setRemoteDescription() resolves, and always await pc.addIceCandidate(c).catch(...) so failures are logged rather than swallowed.
A <video> element with a remote srcObject will not autoplay without muted, and on iOS it needs playsinline or it hijacks the screen into fullscreen playback. element.play() returns a promise that rejects with NotAllowedError; unhandled, it produces nothing in the console beyond an unhandled rejection. An AudioContext constructed at page load starts in state suspended and stays there, so any Web Audio processing graph silently produces no output.
Put everything behind one user gesture: await audioContext.resume() and await videoEl.play().catch(...) in the click handler for your Join button. Set autoplay, playsinline, and initially muted on every media element, and unmute after the gesture.
On carrier-grade NAT — increasingly the default on mobile and on some residential fibre — your srflx candidate's address is itself inside 100.64.0.0/10 (RFC 6598) or another private range, because the STUN server sat behind the same CGNAT tier or because a second layer of NAT exists above it. The candidate looks perfectly normal in your logs, and it is completely unroutable from outside the carrier. Two users on the same carrier can sometimes reach each other; a user on that carrier and one anywhere else cannot.
Never treat the presence of a srflx candidate as evidence of reachability. Check whether the srflx address is in 10/8, 172.16/12, 192.168/16, or 100.64/10 and downgrade your expectations accordingly, and always have TURN available. The only signal that matters is candidate-pair.state === 'succeeded'.
RFC 4787 REQ-5 asks for a 2-minute minimum UDP mapping timeout; consumer CPE routinely uses 30–60 s. WebRTC survives this because STUN consent freshness (RFC 7675) sends a binding request roughly every 5 s, which doubles as a keepalive — even on a data-channel-only connection with no media. The failure mode is mobile: when iOS Safari backgrounds the tab, JavaScript timers and often the whole networking stack are suspended, consent checks stop, and the mapping dies in under a minute. The user returns to a connection that ICE reports as disconnected or failed.
Listen for visibilitychange and, on resume, check pc.iceConnectionState and call pc.restartIce() if it is not connected. Do not rely on automatic recovery on iOS. For long-lived data channels, consider tearing down on background and reconnecting on foreground rather than fighting the OS.
Chrome replaces host candidates with <uuid>.local names to avoid leaking private IPs, and the remote resolves them over mDNS. Most enterprise and guest Wi-Fi blocks multicast between clients, so the name never resolves. The two peers then fall back to srflx, which requires the router to support hairpinning (RFC 4787 REQ-9) — many do not. The result is two laptops on the same desk relaying their video through a TURN server on another continent, with no error anywhere and a bandwidth bill as the only symptom.
Instrument candidateType on the selected local candidate and alert when relay exceeds a few percent. For controlled deployments, IT can enable mDNS/Bonjour forwarding on the WLAN. For diagnosis, toggle chrome://flags/#enable-webrtc-hide-local-ips-with-mdns to see raw host candidates and confirm the LAN path works at all.
On an IPv6-only network with DNS64/NAT64 — T-Mobile US, several corporate networks, and Apple's mandated test configuration — an IPv4 literal in RTCIceServer.urls is unreachable because there is no IPv4 path and no hostname for DNS64 to synthesise a NAT64 address from. Gathering simply produces no relay candidates. If you use turns: with an IP literal you also fail certificate validation, since the certificate almost certainly has no IP SAN.
Always configure TURN by hostname, never by address, and make sure the hostname has an AAAA record or is reachable through DNS64. Test on an IPv6-only network (macOS can create one via Internet Sharing with NAT64 enabled) before shipping to mobile.
XOR-MAPPED-ADDRESS exists because early NATs rewrote anything in a payload that resembled an address. Application-layer gateways on consumer routers — often on by default, often labelled "SIP ALG" — still rewrite SDP bodies and occasionally still mangle STUN. The signature is a srflx candidate with a plausible address but the wrong port, so connectivity checks are sent to a mapping that does not exist and every pair fails while STUN itself appears to have succeeded.
When a specific user's pairs all fail with correct-looking candidates, have them disable SIP ALG on their router. Compare the srflx port against the local port: a NAT that preserves ports on some flows and not others, or reports a port nothing is listening on, is the tell.
stun:host:3478?transport=tcp is accepted in the configuration and then effectively ignored: browsers gather srflx over UDP only. If UDP to your STUN server is blocked, you get zero srflx candidates and no diagnostic — gathering just completes with fewer candidates than you expected, and ICE proceeds straight to relay. Any username and credential you supply alongside a stun: URL are also silently discarded; STUN in WebRTC gathering is unauthenticated.
Count candidate types during gathering. Zero srflx with non-zero host is a strong signal that UDP is blocked and that this session will relay. Do not put credentials on stun: entries; if you need authenticated fallback, that is what the TURN entry is for.
You get one srflx candidate per (local interface, STUN server) pair. A laptop with Wi-Fi, Ethernet, and a VPN adapter plus five STUN servers gathers fifteen srflx candidates that mostly duplicate each other, and the candidate-pair matrix grows as the product of both sides' candidate counts. More pairs means more connectivity checks competing for the same pacing budget, so time-to-connect gets worse. Chrome also caps the number of pairs it will check and will prune the ones you cared about.
One STUN server, or one plus a backup. Use iceCandidatePoolSize: 1 to warm gathering before you have a peer if setup latency matters. If you are chasing connect time, count pairs in getStats() — a healthy two-party call has single digits, not fifty.
The binding requests browsers send during connectivity checks are RFC 8445 checks: they carry USERNAME, MESSAGE-INTEGRITY, FINGERPRINT, PRIORITY, and ICE-CONTROLLING/ICE-CONTROLLED, and use short-term credentials derived from the SDP ufrag and pwd. A homegrown STUN server that answers gathering requests correctly can still break connectivity if it mishandles comprehension-required attributes in the 0x0000–0x7FFF range, which must produce a 420 with UNKNOWN-ATTRIBUTES rather than being ignored.
Do not write your own. Use coturn or a maintained implementation. If you must debug one, Wireshark decodes STUN natively and will show you the attribute list and whether MESSAGE-INTEGRITY validated.
Anything in RTCIceServer.credential is readable by the user, by any script on the page, and by anyone who opens devtools; it is also recoverable from pc.getConfiguration(). A long-lived shared secret is scraped within weeks of shipping and your relay becomes free bandwidth for someone else's file transfers or, worse, a laundering hop. The bill arrives before the alert does, because you are almost certainly not monitoring allocations by credential.
Use time-limited credentials: the coturn REST scheme where username is <unix-expiry>:<userid> and credential is the base64 HMAC-SHA1 of that username under a server-side static-auth-secret. Issue them from your authenticated signaling endpoint with a lifetime of minutes, and alert on relayed bytes per credential.
Plain TCP on port 443 is blocked by TLS-inspecting proxies that expect a ClientHello and drop anything else, so turn:host:443?transport=tcp fails exactly where you most need it. Real TLS via turns: works. Two further constraints: browsers route TURN/TCP through a configured HTTP proxy but cannot proxy TURN/UDP at all, and turns: with an IP literal fails certificate validation and produces no relay candidates with no error surfaced to JavaScript.
Configure all four in order of preference: turn:host:3478?transport=udp, turn:host:3478?transport=tcp, turns:host:443?transport=tcp. Use a hostname with a publicly trusted certificate. Then verify the TLS path actually works by testing with UDP blocked at your firewall — iceTransportPolicy: 'relay' alone does not test it.
RFC 8656 gives allocations a 600 s default lifetime, permissions 300 s, and channel bindings 600 s, each refreshed independently. Browsers handle all of this. Native clients and SFUs frequently refresh the allocation and forget the permission, which produces the worst failure mode in the catalogue: at the five-minute mark media stops in one direction while ICE still reports connected, consent checks still pass on the other direction, and nothing logs an error.
If you own a TURN client, refresh permissions at 240 s regardless of traffic. If you are debugging, the tell is one-way media beginning at almost exactly 5:00 into a relayed call — check inbound-rtp.packetsReceived flat while transport.bytesSent keeps rising.
Every relayed byte is received and re-sent, so a relayed 1 Mbit/s bidirectional call costs 4 Mbit/s of server traffic. ChannelData framing adds 4 bytes per packet once the channel is bound (36 bytes before that, using Send/Data indications), and over TURN/TLS you additionally pay TCP and TLS record framing. At 1000 concurrent relayed video calls you are looking at multiple gigabits of egress, which is usually the dominant line item in a WebRTC infrastructure bill.
Measure your relay rate as a first-class SLI, and place TURN servers close to users so the doubled traffic is at least short-haul. Note that iceTransportPolicy: 'relay' is also the only reliable way to test TURN — without it, host and srflx candidates succeed in your office and you never discover that your relay has been misconfigured for a month.
RFC 8445 §5.1.2.1 gives priority as (2^24)*type-pref + (2^8)*local-pref + (256 - component-id), with recommended type preferences of host 126, peer-reflexive 110, server-reflexive 100, and relay 0. Everyone remembers host > srflx > relay and forgets that a peer-reflexive candidate — one discovered from an inbound check arriving from a mapping you never gathered — beats your own srflx. This is why the selected pair sometimes uses an address that appears nowhere in either side's signalled candidate list, which looks like a bug in your logging.
Expect prflx candidates in getStats() and do not treat "address not in our candidate list" as corruption. When reconciling logs, remember that prflx candidates are never signalled by definition.
ICE reaches connected on the first successful pair but keeps checking, and it will switch to a better pair later — a relay-to-direct upgrade, or a new interface appearing. selectedCandidatePairId then points at a different candidate-pair object with its own counters starting from zero and its own currentRoundTripTime. Your RTT graph shows a discontinuity and your bytes-per-second calculation shows a negative delta at exactly the moment the connection got better.
Track selectedCandidatePairId as part of your state and annotate the timeline when it changes, rather than treating pair stats as a continuous series. For aggregate throughput, use the transport object, which survives pair switches.
ICE-TCP candidates carry tcptype active, passive, or so (simultaneous open). Browsers gather active candidates only, and an active candidate can connect only to a passive one. Since neither browser offers a passive candidate, two browsers can never establish a direct TCP connection to each other; the TCP candidates exist solely to reach a TURN server's passive listener. Seeing tcptype active in your SDP and concluding "we have a TCP fallback path" is wrong unless a TURN server is in the configuration.
Do not count TCP candidates as a P2P fallback. If UDP is blocked, TURN/TCP is your only option, which is another reason the relay configuration is not optional.
The controlling agent — normally the offerer — nominates. RFC 8445 specifies regular nomination; libwebrtc historically used aggressive nomination, which sets the USE-CANDIDATE flag on ordinary checks and effectively nominates the first thing that works. An SFU that implements only regular nomination against an aggressively nominating browser will see the selected pair chosen earlier and differently than it expected, sometimes landing on a relay pair that beat the direct pair by milliseconds. Role conflicts on ICE restart are resolved by the tiebreaker in ICE-CONTROLLING/ICE-CONTROLLED, and a buggy implementation can end up with both sides controlled and nothing nominated.
If you write server-side ICE, implement both nomination styles and the role-conflict tiebreaker. When a pair selection looks premature, log all pairs' state and nominated flags — the good pair is usually there in succeeded state, just not chosen.
Gathering completion is signalled by onicecandidate firing with event.candidate === null; on the wire the equivalent is a=end-of-candidates (RFC 8838). If you never relay it, the remote cannot know your list is complete, so it keeps waiting instead of declaring failed — you get a call that hangs in checking for tens of seconds rather than failing fast and letting you retry. addIceCandidate(null) versus addIceCandidate({ candidate: '' }) has also differed across browsers; older Firefox threw on one of them.
Signal end-of-candidates explicitly and apply it with await pc.addIceCandidate({ candidate: '', sdpMid: mid }) wrapped in a try/catch. Also cap your own patience: if iceConnectionState is still checking after 10–15 s, tear down and retry rather than waiting for the browser's timeout.
Chrome does continual gathering: plug in Ethernet or move from Wi-Fi to LTE mid-call and new candidates appear through onicecandidate on the existing ufrag, and ICE can migrate without renegotiation. Firefox historically stops gathering at completion, so a Firefox client that changes networks has no candidate for the new interface and the connection dies rather than migrating. The same call survives a network change on Chrome and drops on Firefox, which is easy to misattribute to the network.
Listen for the connection going disconnected and call pc.restartIce() after a short debounce (2–3 s) on every browser; it is a no-op cost on Chrome and the only recovery on others. The Network Information API and online/offline events are useful triggers but are not reliable on their own.
pc.restartIce() (or createOffer({ iceRestart: true })) generates a fresh ufrag and password and restarts the checks. It deliberately does not restart DTLS: the same keys and the same certificate continue, so media resumes immediately once a pair succeeds and no keyframe is needed. What it does do is create new candidate-pair objects and, in some browsers, a new transport, so counters reset. restartIce() is also comparatively recent — Safari added it around 15.4.
Feature-detect 'restartIce' in RTCPeerConnection.prototype and fall back to createOffer({ iceRestart: true }). Treat post-restart counter resets as an epoch boundary, and do not expect a media freeze — if media does not resume after a successful restart, the problem is not ICE.
iceGatheringState reaches complete only when every server has answered or timed out, and a TURN host that accepts TCP connections but never responds burns the full timeout — on the order of 10 s in Chrome. Code that waits for gathering complete before sending a non-trickle offer therefore adds ten seconds of dead air to every call whenever one TURN entry is unhealthy, and nothing in the JavaScript API tells you which server is at fault.
Trickle if the peer supports it (advertised as a=ice-options:trickle). If you must send a complete offer — most WHIP servers, many gateways — race gathering against a 2–3 s timeout and send whatever you have. Health-check your TURN servers out of band; a browser will not tell you one is down.
JSEP (RFC 8829) treats the SDP produced by createOffer as an opaque artifact of the implementation's state. Current Chrome validates what you hand back: removing codecs or entire m-lines is tolerated, but changing the ICE ufrag, password, DTLS fingerprint, MID values, or m-line order raises InvalidModificationError from setLocalDescription. Munges that worked for years now throw, and munges that still succeed can leave the internal state and the SDP disagreeing, which surfaces later as an inexplicable renegotiation failure.
Treat a munge that throws as a design signal. Codec order is setCodecPreferences; bitrate is setParameters; simulcast is addTransceiver({ sendEncodings }); direction is transceiver.direction. The residual legitimate munges are narrow: Opus fmtp parameters and Chrome's x-google-* bitrate hints.
Chrome, Edge, most Electron apps, and the mobile SDKs all embed libwebrtc, so a libwebrtc bug reproduces across what look like independent implementations. WebKit also uses libwebrtc but on its own branch, wrapped in Apple's capture stack and VideoToolbox codecs — so Safari's API is close to Chrome's while its media behaviour (AEC, hardware H.264 quirks, capture constraints) is genuinely different. Firefox is the only large independent stack, which is why it is the one that catches your spec violations.
Test on Firefox to find spec bugs and on Safari/iOS to find media bugs; testing Chrome and Edge is testing the same engine twice. When a bug reproduces identically in Chrome and your native SDK, search the libwebrtc issue tracker before your own code.
The 1.0 specification kept moving after browsers shipped, so support is per-API, not per-version: restartIce, setCodecPreferences, transceiver.stop, RTCRtpScriptTransform, jitterBufferTarget, and setParameters with encodings[].codec all landed at different times in different engines. adapter.js smooths some of this and silently no-ops other parts, which is worse than an exception because your code takes the wrong branch believing the shim worked.
Probe the exact member: 'setCodecPreferences' in RTCRtpTransceiver.prototype. Where a shim might no-op, verify the effect rather than the existence — check getStats() for the codec you asked for, not the fact that the call did not throw.
{ width: 1920 } is shorthand for { width: { ideal: 1920 } }, which is advisory. Chrome will hand you 640x480 from a cheap webcam and consider the constraint satisfied. Only exact, min, and max are mandatory and can produce OverconstrainedError. Separately, an aspectRatio constraint is satisfied by cropping, so asking for 16:9 from a 4:3 sensor silently narrows the field of view and users report "the camera zoomed in".
Read track.getSettings() after acquisition — that is the only truth; getConstraints() merely echoes your request. Use min for hard floors and be ready to catch OverconstrainedError and retry with a relaxed set rather than failing the join.
Without an active or previously granted permission, enumerateDevices() returns entries with empty label and (depending on browser) empty or placeholder deviceId. Firefox returns one anonymous entry per kind. Safari returns labels only after permission and discards the grant on reload, so your device picker is blank again every refresh. deviceId is also origin-scoped and rotates when site data is cleared, which invalidates any "remember my microphone" preference you stored.
Call getUserMedia() first with loose constraints, then enumerateDevices(), then let the user refine with applyConstraints() or a second gUM. Persist device choice by label and groupId as a fallback for when deviceId no longer matches, and always degrade to the default device rather than erroring.
On iOS, acquiring a second camera or microphone stream stops the existing one: the first track fires ended and its video element goes black. This makes the common desktop pattern — a device-preview stream in a settings modal while the call stream runs — silently break the call. iOS also requires the first getUserMedia to occur in a user-gesture task, and grants permission per page load rather than persistently for many configurations.
On iOS, never hold two capture streams. Preview by rendering the existing call track, and switch devices with applyConstraints({ deviceId }) or by acquiring a new stream after stopping the old one and then replaceTrack(). Always attach an ended handler to every track and treat it as a recoverable event.
track.enabled = false transmits black frames and silence — roughly 10–30 kbit/s of nothing — and keeps the camera light on. track.stop() stops the capture but leaves the sender with a dead track, so the remote sees a frozen last frame rather than any kind of "muted" signal, and there is no event for "the other side muted". Clones from track.clone() hold the device open independently, so the indicator light stays on until every clone is stopped.
Use track.enabled = false for transient mute and sender.replaceTrack(null) for a real stop that leaves the m-line intact and stops transmission. Signal mute state in your application protocol; do not try to infer it from media. Track every clone you create.
signalingState, iceGatheringState, iceConnectionState, and connectionState are separate machines that legitimately disagree during recovery. iceConnectionState goes disconnected after roughly 5 s of failed consent checks and failed only much later; connectionState aggregates ICE and DTLS and can lag. disconnected is transient by design and recovers on its own constantly on mobile — teardown logic wired to it produces reconnect storms that are far more visible to users than the two-second gap would have been.
Drive UI from connectionState. On disconnected, show a subtle indicator and start a 3–5 s timer; only escalate to restartIce() if it has not recovered. Reserve teardown for failed and for closed.
pc.close() deliberately dispatches no state-change events, so your onconnectionstatechange handler never sees closed and any cleanup hanging off it does not run. It also does not stop the local tracks, so the camera light stays on. And getStats() after close resolves with an empty report in Chrome and rejects with InvalidStateError in Firefox, which means the final snapshot you wanted for your call-quality beacon is gone.
Snapshot getStats() and post your beacon before calling close(). Do your own cleanup synchronously around the close call: stop every local track, null out srcObject, clear intervals, and update UI state explicitly.
signalingState includes have-local-pranswer and have-remote-pranswer, and you can construct a description with type: 'pranswer'. Nothing consumes it: there is no early-media use case implemented in browsers, and a provisional answer buys you nothing over just applying the real answer. Code paths written to handle all five signaling states are handling two that will never occur, which is harmless but obscures the two-state reality that matters (stable versus not).
Write your negotiation logic against stable, have-local-offer, and have-remote-offer only, and assert on anything else so a genuine surprise is loud.
removeTrack() sets the transceiver's direction to recvonly or inactive and leaves it — and its m-line — in place forever. getTransceivers() returns insertion order before the first negotiation and m-line order afterwards, so an index you cached across a renegotiation now points somewhere else. transceiver.stop() is the only API that retires one (setting currentDirection to stopped and zeroing the port), and it shipped late enough that you must feature-detect it.
Hold references to transceiver objects, never indices, and re-derive the mapping from mid after each renegotiation. For repeated add/remove patterns (screenshare toggling), keep one transceiver and use replaceTrack().
Chrome removed Plan B in M96. Passing { sdpSemantics: 'plan-b' } does not throw; it is ignored, and you get Unified Plan. The consequence is on the receiving side: Plan B SDP puts multiple streams on one m-line distinguished by a=ssrc-group:SIM and multiple a=ssrc blocks, and a Unified Plan browser parses that as a single stream. Legacy SFUs and recording pipelines that still emit Plan B see one participant where there were five, with no parse error.
Migrate the server. If you are diagnosing, count m= lines in the remote description against the number of remote participants — Plan B gives you two m-lines for any number of people.
addTrack() reuses the first compatible transceiver that has no sending track — including one created as recvonly by a previous remote offer. So the m-line your track lands on depends on negotiation history, not on the order of your calls. For an SFU that expects video at a particular MID, or for simulcast where encodings must be declared at transceiver creation, this is not a detail you can leave to chance.
Use addTransceiver(track, { direction, streams, sendEncodings }) whenever ordering, direction, or encoding configuration matters, and reserve addTrack() for simple two-party calls. Verify placement by reading sender.transceiver.mid after the first negotiation.
pc.addTrack(track) with no stream argument makes Chrome write a=msid:- <track-id>. The remote side then gets an ontrack event whose streams array may be empty or contain a synthesised stream, depending on browser. Any code doing const id = event.streams[0].id to route remote media into a UI slot throws or mis-routes — and it works fine in the two-party demo where you always passed a stream, then breaks against an SFU that does not.
Always pass a stream: pc.addTrack(track, stream). On the receive side, key on event.transceiver.mid and correlate to participants through your own signaling metadata, never through stream or track ids chosen by a remote implementation.
Each add/remove cycle leaves an m-line behind, and each m-line in a modern browser is 20–40 lines including a dozen payload types, RTX entries, header extensions, and ICE attributes — roughly 1.5–3 KB. A conference where people toggle screenshare and camera for an hour can reach tens of kilobytes of SDP per exchange. That is fine over a WebSocket and fatal over SIP/UDP, some serverless function payload limits, and any transport with a 8–16 KB message cap.
Reuse transceivers rather than cycling them, and if you are near a limit, compress the SDP for transport (gzip plus base64 typically gets 8:1) or negotiate over a transport without a small cap. Log SDP length as a metric; it is a leading indicator of a renegotiation bug.
The event is queued to a microtask-ish checkpoint, so a batch of addTrack calls produces one event — but only if they happen in the same turn. Historically Chrome has also fired it after setRemoteDescription in circumstances where nothing needed negotiating. Because your handler is async, the state you checked before the first await is stale by the time you use it, which is how "we guarded on signalingState" still produces InvalidStateError.
Use the canonical guard: a makingOffer flag set synchronously at the top of the handler and cleared in a finally, plus await pc.setLocalDescription() with no argument so you never construct a description that can go stale. Re-check pc.signalingState === 'stable' after every await, not before.
The polite peer's move is await pc.setLocalDescription({ type: 'rollback' }), which returns the connection to stable so the incoming offer can be applied. Firefox implemented it first; Chrome added local rollback around M80 and Safari around 15.4. Critically, rollback restores the signaling state, not your application state: the addTrack that triggered the offer is still there, and the transceivers it created still exist. Implementations have also differed on whether transceiver directions are fully restored.
After rolling back, expect onnegotiationneeded to fire again for the still-pending change and let the normal flow re-offer. Do not undo application state on rollback; that double-undo is a classic source of vanished tracks.
The impolite peer ignores a colliding offer, but candidates for that ignored offer keep arriving over signaling. Applying them throws — usually OperationError or InvalidStateError. Perfect negotiation's published pattern explicitly rethrows only when ignoreOffer is false, and implementations that skip that detail fill their error dashboards with candidate failures that are entirely expected, which then masks the real ones.
Keep the ignoreOffer flag and write try { await pc.addIceCandidate(c) } catch (e) { if (!ignoreOffer) throw e }. Count suppressed errors as a metric so you can still see if the rate is abnormal.
replaceTrack() needs no renegotiation, which makes it the single most effective way to avoid glare — camera switches, mute, screenshare-to-camera all become local operations. The catch is resolution: swapping a 640x480 camera for a 1920x1080 screen capture changes the encoded resolution mid-stream without any signaling, and some Android hardware decoders mishandle the change, producing green or garbage frames until the next keyframe. Kind cannot change: audio for video throws InvalidModificationError.
Prefer replaceTrack() anyway, but pair large resolution changes with a keyframe request from the receiving side if you control it, or accept a brief artifact. If you own the SFU, force a PLI on detected resolution change.
The offerer sends a=setup:actpass; the answerer picks active (it sends the ClientHello) or passive. Munge both sides to passive and each waits for the other: ICE connects, consent checks pass, iceConnectionState reads connected, and connectionState sits at connecting until it eventually fails with no message identifying DTLS as the cause. The same deadlock occurs when a gateway hard-codes passive in both roles.
Never touch a=setup. When you see ICE connected but connectionState stuck, check the transport stats object: dtlsState stuck at connecting with a populated selectedCandidatePairId is the fingerprint of a role problem, and dtlsCipher being absent confirms no handshake completed.
The DTLS certificates are self-signed. All that binds them to the peer is the a=fingerprint line carried over your signaling channel — so the security property is "encrypted against the network, given that signaling is honest". Your own signaling server can substitute fingerprints and MITM every call, and no browser indicator changes. Marketing copy that says "end-to-end encrypted" on a plain WebRTC deployment is describing hop-by-hop encryption.
If you need protection from your own infrastructure, you need media-layer E2EE (insertable streams or SFrame) plus an out-of-band identity binding — a short authentication string, or fingerprint pinning against keys distributed through a channel you do not control. Otherwise be accurate in what you claim.
Each RTCPeerConnection generates a fresh certificate. ECDSA P-256 (the default) takes single-digit milliseconds; RSA-2048 takes hundreds of milliseconds to over a second on low-end Android, which shows up as connection setup latency. RTCPeerConnection.generateCertificate() lets you pre-generate and pass the certificate into the constructor, amortising the cost across many connections — but a stable certificate means a stable fingerprint, which is a cross-session identifier, and certificates have an expires value (about 30 days by default) after which DTLS fails with nothing useful in the error.
Pre-generate at app start for mesh or multi-PC topologies, keep ECDSA, and check expires before reuse. Do not persist a certificate across sessions unless you have decided the identifier is acceptable.
a=crypto (SDES, RFC 4568) was removed from browsers long ago and there is no flag to restore it, so legacy SBCs offering only SDES cannot interoperate at all. Among the ciphers that remain, AEAD_AES_128_GCM is negotiated only if both sides offer it; otherwise you fall back to AES_CM_128_HMAC_SHA1_80, which is both slower and has a shorter authentication tag. Nothing surfaces this choice in the UI, and an old SFU can quietly pin every call to the weaker suite.
Read srtpCipher and dtlsCipher from the transport stats object and record them per session. If your SFU is negotiating SHA1_80 across the board, that is a configuration fix with measurable CPU upside.
1920 x 1080 in 8-bit 4:2:0 is 3.1 MB per frame; at 30 fps that is 93 MB/s, or about 750 Mbit/s. This is why raw-frame processing in JavaScript is a performance discipline rather than a coding style: every new Uint8Array(frame) allocates and copies 3 MB, thirty times a second, which GC-thrashes and drops frames long before your actual algorithm becomes the bottleneck. Worse, WebCodecs and MediaStreamTrackProcessor hand you frames from a small fixed pool — forgetting frame.close() starves the pool and capture stalls silently after a handful of frames.
close() every VideoFrame and AudioData in a finally. Reuse one destination buffer with frame.copyTo(buffer). Do the work in a worker, and prefer WebGL/WebGPU or an OffscreenCanvas over CPU pixel loops. If capture freezes with no error, suspect a leaked frame before anything else.
Perceptual coding is tuned for the content it expects. track.contentHint takes 'motion', 'detail', or 'text' for video and switches the encoder between prioritising frame rate and prioritising spatial fidelity — and it is empty by default even for getDisplayMedia tracks. Shared code and spreadsheets therefore arrive blurred at low bitrate, and the usual response is to raise the bitrate rather than to set a one-line hint. Chrome implements it, Firefox partially, Safari largely ignores it.
Set track.contentHint = 'text' (or 'detail') immediately after getDisplayMedia, and 'motion' for camera and video playback sharing. Pair it with degradationPreference: 'maintain-resolution' on the sender, since for text you would rather lose frame rate than pixels.
"Throw away what you cannot hear" holds once. Decode and re-encode the result and the second encoder is now optimising a signal whose masking structure has already been destroyed, so it discards information the first pass was relying on. Each tandem stage costs roughly 0.2–0.5 MOS for audio and a visible generation of blocking for video, and a cascade of two MCUs plus a PSTN leg can take a clean Opus stream down to something users describe as "underwater".
Forward, do not transcode: an SFU that relays the original encoded frames has zero generation loss by construction. When transcoding is unavoidable (PSTN, recording, legacy MCU), do it exactly once and at the highest bitrate you can afford, and never chain two transcoders.
maxaveragebitrate, stereo, usedtx, useinbandfec, maxplaybackrate, cbr, and ptime are receiver preferences: putting them in your offer asks the far end to change. This is why "we munged the offer to get 128 kbit/s stereo audio" so often changes the wrong direction, and why the two sides of a call can legitimately run at different bitrates and channel counts. maxaveragebitrate is also in bits per second (max 510000) while b=AS beside it is in kilobits.
To raise your own audio bitrate, use sender.setParameters() with encodings[0].maxBitrate in bits per second — Chrome honours it for audio. To get better audio from the peer, munge the answer you send them (or the offer, if you are the offerer) and verify with their outbound-rtp stats, not yours.
With usedtx=1, Opus emits roughly one packet per 400 ms during silence instead of fifty per second. Every packet-rate-derived metric then breaks: loss percentage computed over a tiny denominator swings to absurd values, RTCP-derived estimates destabilise, and SFUs that infer active speakers from packet rate mis-detect. Because most calls are mostly silence, DTX affects the majority of your samples, and the resulting "40 % packet loss" alerts are pure artifact.
Measure audio quality with sample counters, not packet counters: concealedSamples / totalSamplesReceived, minus silentConcealedSamples, is the metric that means something. For speaker detection, use the audio-level RTP header extension (RFC 6464), which the SFU can read per packet.
useinbandfec=1 is on by default, but it does not mean FEC is being emitted. Opus embeds a low-bitrate copy of the previous frame only, only when the encoder believes loss warrants it, and it learns that from RTCP feedback — so it engages seconds after loss begins and disengages just as slowly. It also steals bitrate from the primary encoding, so enabling it on a constrained link lowers baseline quality, and it can only ever recover single isolated losses, never a burst.
Expect concealment, not repair, for the first few seconds of any loss episode. RED (a=rtpmap:63 red/48000/2) wrapping Opus gives redundancy that does not wait for feedback, at the cost of roughly doubling audio bitrate; that is the right trade for high-value low-bandwidth audio. Verify with fecPacketsReceived/fecPacketsDiscarded.
The default 20 ms frame gives 50 packets/s per direction. ptime=60 cuts that to about 17, which materially helps on relayed and heavily policed links where per-packet cost dominates — but it adds 40 ms of latency, triples the damage of a single lost packet, and delays FEC by the same amount. Safari has historically ignored ptime entirely, and Opus's 2.5 ms frames are not reachable from any browser API.
Only raise ptime when you have measured a packet-rate bottleneck, and expect it to be advisory. Note the sender chooses the frame size, so this is another parameter you must place on the correct side of the offer/answer.
WebRTC video is I420 with a colour matrix and range that are frequently unsignalled; getUserMedia tracks routinely report VideoFrame.colorSpace with null or unspecified members. Browsers then apply their own default matrix (BT.601 versus BT.709) and range handling when compositing to <video> and when you drawImage() to a canvas. Pixel values read back with getImageData() therefore differ between Chrome and Safari for the same frame, which breaks anything doing exact-match comparison: watermark detection, visual regression tests, and E2EE integrity checks over pixels.
Never build a correctness test on exact pixel values across browsers; use a perceptual difference threshold. If you need deterministic pixels, stay in the encoded domain (insertable streams) or carry your signal in metadata rather than in luma.
All practical WebRTC video is 8-bit 4:2:0: chroma is at half resolution horizontally and vertically. Thin coloured lines and coloured text lose three quarters of their chroma samples, and red-on-white — an IDE error underline, a spreadsheet's negative numbers — degrades to a muddy fringe even at high bitrate. VP9 profile 2 and AV1 support 4:4:4, but no browser negotiates 4:4:4 over RTP, so there is no configuration that fixes this.
For text-heavy screenshare, buy fidelity with resolution rather than chroma: share at native resolution, set contentHint = 'text', and use degradationPreference: 'maintain-resolution'. Advise users away from thin saturated colours in shared content, and consider a data-channel-based document view for anything that must be pixel-crisp.
I420 needs even width and height. getDisplayMedia on a window can hand you 1281 x 721, and some capture devices produce odd sizes too. Chrome rounds down without telling you, so track.getSettings().width and outbound-rtp.frameWidth disagree by a pixel — enough to break assertions and aspect-ratio math. Some Android hardware H.264 encoders require multiples of 16 and pad with whatever is in memory, which appears as a strip of noise along the bottom edge that only some users see.
Treat frameWidth/frameHeight from outbound-rtp as the encoded truth and getSettings() as the capture request. Use applyConstraints or scaleResolutionDownBy to land on even (ideally multiple-of-16) dimensions rather than hoping.
On macOS with a P3 or HDR display, getDisplayMedia produces an 8-bit SDR frame that has been tone-mapped and gamut-converted, so shared content looks washed out or shifted relative to what the presenter sees. Screenshots of the same screen look correct, which makes users certain the bug is in your app. There is no API to request a wider gamut or 10-bit capture for RTP.
Nothing to fix in code; set expectations. If colour accuracy matters (design review, medical), send the source asset out-of-band over a data channel or HTTP and render it locally rather than streaming pixels.
Bidirectionally predicted frames require buffering future frames, so real-time encoders do not emit them; WebRTC video is I and P frames only. There is also no periodic keyframe interval: after the initial IDR, browsers emit keyframes only when asked (PLI/FIR) or on a resolution change. So a "GOP length" does not exist to tune, and a client joining an ongoing stream has nothing to decode until someone requests an IDR. Legacy MCUs that do send B-frames will decode in Chrome but confuse frame timing.
If you write an SFU, cache the most recent keyframe per layer and send a PLI upstream the instant a subscriber attaches. Do not attempt to force periodic keyframes as insurance — they are expensive (often 10x a P-frame) and they collide with the bandwidth estimator's ramp.
This is the single most common SFU bug. inbound-rtp.packetsReceived climbs, framesReceived may climb, framesDecoded stays at 0 and keyFramesDecoded stays at 0, and the user sees black. It happens whenever the keyframe request path is missing or rate-limited into oblivion — and it is intermittent in testing, because if a keyframe happens to be generated for another reason within a second or two of joining, everything works.
Alert on keyFramesDecoded === 0 with packetsReceived > 0 after 2 s; that single check catches the whole class. On the client, you cannot request a keyframe from JavaScript, so recovery must come from the server or from a renegotiation.
The encoder's target comes from the congestion controller. maxBitrate is a cap, not a target, and there is no standard minBitrate. When the estimate drops, degradationPreference decides the sacrifice: maintain-framerate drops resolution, maintain-resolution drops frame rate, balanced does both. The default depends on content hint and browser, so the same code degrades differently for camera and screen, and users report "it goes blurry" or "it goes choppy" for reasons you did not choose.
Set it explicitly per use case: maintain-resolution for screenshare and slides, maintain-framerate for talking heads and motion. Set maxFramerate in the encoding for screenshare (5–15 is plenty) so the encoder spends bits on pixels.
outbound-rtp.qualityLimitationReason is one of none, cpu, bandwidth, or other, and qualityLimitationDurations gives cumulative seconds in each. This turns the most common support ticket from a guess into a lookup: cpu means the encoder cannot keep up (thermal throttling, too many simulcast layers, a software AV1 encoder on a laptop), bandwidth means the estimator is the constraint. qualityLimitationResolutionChanges counts how often the encoder had to drop resolution.
Report the qualityLimitationDurations breakdown as a share of call duration in your telemetry. It is the highest-signal-per-byte video metric available and it distinguishes "buy better bandwidth" from "reduce encoder load", which are opposite fixes.
RTCRtpSender.getCapabilities('video') and RTCRtpReceiver.getCapabilities('video') return different lists, and both vary by machine: a Chromebook may decode VP9 without encoding it; an Android device lists H.264 only while a hardware encoder is available. setCodecPreferences() lives on the transceiver rather than the peer connection, must be called before the createOffer that carries that m-line, and throws InvalidModificationError if you pass an entry the browser does not have. In Chrome it constrains both directions on that m-line, which surprises people trying to change only one; Safari shipped it late (around 15.4).
Query both capability lists at runtime on the actual device and reorder the objects you were given rather than constructing entries by hand. For send-only selection on recent Chrome, setParameters() with encodings[].codec avoids the m-line entirely. Always confirm through getStats() — join outbound-rtp.codecId to the codec object — and record the negotiated codec per session so you learn your fleet's real distribution.
Android hardware H.264 encoders commonly enforce an internal bitrate floor of 100–200 kbit/s at low resolutions and simply overshoot your cap; several emit keyframes only on explicit request; some produce SPS/PPS that Safari's decoder rejects, giving you a one-way video failure between two specific device families. Most importantly many SoCs support a single simultaneous hardware encode session, so a three-layer simulcast request quietly returns two layers or falls back to software with a CPU cliff.
Count your outbound-rtp objects: if you asked for three encodings and got two, a layer was dropped. Toggle chrome://flags/#disable-accelerated-video-encode as an A/B to confirm the hardware path is at fault. For Android-heavy fleets, prefer VP8 with software simulcast or restrict to two layers.
Negotiating AV1 in a browser today almost always means libaom in software. It is excellent for low-bitrate screenshare, and it will thermally throttle a laptop at 720p30 camera, showing up as qualityLimitationReason: 'cpu' and a fan. AV1 also does not do RTP simulcast: you get scalability through scalabilityMode on the first encoding ('L1T3', 'L3T3_KEY'), which is a Chrome-only, non-standard-ish knob that other browsers ignore.
Use AV1 selectively — screenshare and low-bitrate audio-conference-with-video — not as a default. If you enable it, monitor totalEncodeTime/framesEncoded (over roughly 15 ms per frame at 30 fps means you are losing) and be ready to renegotiate down to VP8 or H.264.
Video codecs occupy the dynamic 96–127 range and the assignments differ between browsers, between browser versions, and between m-lines in the same session. A modern video m-line commonly carries 12 or more payload types: several H.264 profile/packetization combinations, VP8, VP9, AV1, an RTX type for each, plus RED and ULPFEC. Anything that hard-codes "96 is VP8" works until it does not, and the failure is a stream that negotiates fine and decodes garbage.
Resolve payload types through a=rtpmap or, better, through getStats(): join inbound-rtp.codecId to the codec object and read mimeType and payloadType. Never write a payload-type constant into an SFU.
The echo canceller subtracts the browser's own render stream. Route remote audio through Web Audio into a MediaStreamAudioDestinationNode, or play it in another tab, another app, or a Bluetooth speaker with unknown latency, and the reference signal no longer matches what the microphone hears — so echo returns despite echoCancellation: true. The subtle case: system or tab audio captured by getDisplayMedia is not in the AEC reference, so sharing a video with sound while unmuted echoes the shared audio back to everyone.
Play remote audio through a plain <audio>/<video> element and keep custom Web Audio processing on the capture side only. For shared audio, mix it into the outgoing stream deliberately (via Web Audio) rather than letting the microphone pick it up, and warn users about external speakers.
echoCancellation, noiseSuppression, and autoGainControl are the standard names; Chrome's old googEchoCancellation family is gone. Toggling them on a live track with applyConstraints() works in Chrome, which rebuilds the processing chain. Safari resolves the promise and changes nothing, so your "high fidelity music mode" toggle reports success and users hear the same noise-suppressed, gain-ridden signal. There is no capability flag that reveals this.
Verify with track.getSettings() after applyConstraints() and fall back to re-acquiring the track with fresh constraints plus replaceTrack(), which works everywhere. Setting the audio contentHint to 'music' is a useful additional signal on Chrome.
inbound-rtp.audioLevel is derived from what the jitter buffer emitted, which during DTX or loss is synthesised comfort noise or concealment output. Polling it at 1 Hz to drive a speaking indicator therefore both misses short utterances and lights up on concealment. It is also a linear 0–1 value, not dBFS, so the naive threshold you picked from a desktop mic is wrong for a phone.
For local level, use media-source.audioLevel or an AnalyserNode on the capture track. For remote speaker detection in a conference, have the SFU read the audio-level RTP header extension (RFC 6464, urn:ietf:params:rtp-hdrext:ssrc-audio-level) and signal active speakers — that is what it exists for.
NetEq hides loss so well that packetsLost understates damage: it does not count packets that arrived too late and were discarded (packetsDiscarded), and it says nothing about how much audio was fabricated. The counters that matter are concealedSamples, silentConcealedSamples, concealmentEvents, insertedSamplesForDeceleration, and removedSamplesForAcceleration. Concealment during DTX silence is harmless, which is why the raw concealment ratio needs the silent portion subtracted before it means anything.
Track (concealedSamples - silentConcealedSamples) / totalSamplesReceived. Under 1 % is good, over 5 % is audible, over 10 % is a bad call. concealmentEvents counts discrete episodes, which distinguishes constant low loss from a few catastrophic stalls — those need different fixes.
Retransmissions ride a separate SSRC bound by a=ssrc-group:FID with their own payload type declared as a=fmtp:<pt> apt=<media-pt>. Depending on browser version those bytes fold into the media outbound-rtp (as retransmittedBytesSent, already included in bytesSent) or appear as a separate object. Under 5 % loss retransmissions can add 10–20 % to your wire rate, so a naive bitrate graph shows the bitrate rising as the network degrades, which reads as a bug in your rate control.
Graph original media rate as bytesSent - retransmittedBytesSent and plot retransmission rate as its own series. A rising retransmission ratio is one of the earliest, cleanest indicators of a deteriorating path — earlier than loss, because NACK repairs it.
Video negotiates a=rtcp-fb:<pt> nack and RTX and genuinely recovers packets. Audio, in the configurations browsers actually use, relies on Opus in-band FEC plus NetEq concealment — so lost audio is never recovered, only fabricated. This is why packetsLost for audio only ever goes up while video's effective loss after repair is much lower than the reported figure, and why the same 3 % loss sounds bad while video looks fine.
Judge audio by concealment, not loss. If audio quality on lossy links is business-critical, RED redundancy (payload type 63, red/48000/2) is the lever that actually helps, at roughly double the audio bitrate — cheap next to video.
flexfec-03 has sat behind a flag in Chrome for years and is not enabled by default, and ULPFEC/RED for video is largely vestigial. So "we turned on FEC" almost always means RED-wrapped Opus FEC for audio, and video has no forward error correction at all — its only repair mechanism is NACK/RTX, which costs a round trip and is useless when RTT exceeds the jitter buffer depth. On a 300 ms satellite or double-relay path, video loss is simply unrecoverable.
Check fecPacketsReceived: if it is zero on video, you have no FEC regardless of what the SDP suggests. On high-RTT paths, buy resilience with temporal SVC (losing a top temporal layer costs frame rate, not decodability) rather than expecting retransmission to work.
Keyframes are often 10x a P-frame. Fifty subscribers joining an SFU stream in the same second each want a keyframe; if the SFU forwards every PLI, the publisher's encoder emits a burst of IDRs that consumes its entire bandwidth estimate, which triggers loss, which triggers more PLIs. Browsers rate-limit their own keyframe generation, which usually saves you, but a badly behaved SFU or a native publisher without that limiter produces a visible quality collapse for everyone at exactly the moment the meeting starts.
Cache the last keyframe per layer at the SFU and serve new subscribers from cache. Coalesce upstream PLIs with a floor of several hundred milliseconds. Watch pliCount and keyFramesEncoded in the publisher's outbound-rtp — a keyframe rate above roughly one per second is pathological.
a=rtcp-fb:<pt> transport-cc means the receiver reports per-packet arrival times (using the transport-wide sequence number header extension) and the sender computes the estimate. a=rtcp-fb:<pt> goog-remb means the receiver computes an estimate and sends a bitrate back. Mixing an old SFU that only speaks REMB with modern browsers relocates ownership of rate control, so your sender-side availableOutgoingBitrate is absent or meaningless and the tuning you did on the sender has no effect.
Grep the negotiated SDP for which feedback types survived, and verify: TWCC in use means candidate-pair.availableOutgoingBitrate is populated and tracks reality. If you inherited a REMB-only server, that is a migration, not a tuning exercise.
Everyone looks for the bandwidth estimate next to the stream it constrains. It is not there: with BUNDLE there is one estimate for the whole transport, so it lives on the selected candidate-pair. availableIncomingBitrate is on the same object and only appears when REMB is in use, which is why it is usually missing entirely. Neither is present until ICE has selected a pair.
Walk transport.selectedCandidatePairId to the pair object and read it from there. Compare it against the sum of your outbound-rtp target bitrates: a large persistent gap means something other than bandwidth (CPU, maxBitrate, encoder floor) is limiting you.
GCC infers congestion from increasing one-way delay gradients. 802.11n/ac/ax aggregate frames, batching packets and releasing them together, which produces recurring 20–40 ms delay steps with no congestion behind them. The estimator reads them as queue buildup and backs off, so clients on excellent Wi-Fi settle at a few hundred kbit/s on a link with 100 Mbit/s available. On LTE the opposite failure occurs: 1–2 s of carrier buffering means delay rises so late that the estimator overshoots badly before it reacts.
Do not tune your application around a single user's estimate; compare availableOutgoingBitrate against a plain HTTP throughput test to see whether the estimator is the limit. Ethernet-versus-Wi-Fi is a legitimate support recommendation for pinned-low estimates, and it is testable in a minute.
GCC starts around 300 kbit/s and ramps multiplicatively while probing with padding, so a high-RTT relayed path can take 20–40 s to reach 2 Mbit/s. There is no standard minBitrate or start bitrate API. The one available knob is a Chrome-specific fmtp munge for VP8/VP9 — a=fmtp:96 x-google-start-bitrate=1200;x-google-min-bitrate=600;x-google-max-bitrate=2500 — and those values are in kilobits per second, unlike maxBitrate next to them in bits. Probing padding also shows up as bytesSent growth with no frame growth, which looks like a leak.
Only raise the start bitrate when you know the path (a corporate LAN, a known-good server leg); on a genuinely thin link, aggressive probing causes the loss it was trying to avoid. Annotate probe traffic in your graphs by tracking frames alongside bytes.
RTCRtpReceiver.playoutDelayHint (seconds, Chrome) and its spec-track successor jitterBufferTarget (milliseconds) are the entire JavaScript surface of the jitter buffer. Setting a low value trades smoothness for latency and raises concealment; setting a high one buys smoothness at the cost of interactivity. Firefox and Safari expose nothing, so a "low latency mode" toggle in your UI is a no-op for a large share of your users, and the two APIs use different units.
Feature-detect both, prefer jitterBufferTarget, and be explicit about units. Measure the effect: jitterBufferDelay/jitterBufferEmittedCount should move, and concealedSamples should rise when you shorten the buffer. If neither changes, the property was ignored.
The audio jitter buffer is not a delay line; it resamples and time-stretches to absorb jitter and to track clock differences. A steadily growing removedSamplesForAcceleration means the sender's clock runs fast relative to yours — routine with cheap USB microphones whose nominal 48 kHz is actually 47,980 Hz, which is 20 ms of drift per minute and over a second per hour. insertedSamplesForDeceleration is the opposite. Neither counter appears in any default dashboard, and both explain "the audio sounds slightly wrong" complaints that no loss metric can.
Track both as rates per minute. Persistent one-sided stretching above roughly 0.1 % of samples is a device clock problem, not a network problem, and no amount of buffer tuning fixes it — a different microphone does.
jitterBufferDelay is total seconds accumulated across all emitted samples or frames; the average is jitterBufferDelay / jitterBufferEmittedCount. The same shape applies to totalDecodeTime / framesDecoded, totalProcessingDelay / framesReceived, totalInterFrameDelay / framesDecoded, and totalRoundTripTime / roundTripTimeMeasurements. Plotting the numerator alone produces a monotonically rising line that people interpret as latency growing without bound.
Difference both members between snapshots and divide the deltas — that gives you the average over the window rather than over the whole call, which is what you actually want. Everything is in seconds; multiply by 1000 exactly once.
NetEq's target delay adapts up fast and decays down slowly — on the order of minutes. A single 500 ms Wi-Fi hiccup thirty seconds into a call can leave 250–300 ms of audio latency in place for the remaining hour, long after the network recovered. Nothing in the UI indicates it, users just experience a conversation where people keep talking over each other, and the only visible trace is an elevated jitterBufferTargetDelay.
Monitor jitterBufferTargetDelay/jitterBufferEmittedCount and, on Chrome, clamp with jitterBufferTarget when it stays high after conditions improve. An SSRC change (via replaceTrack plus renegotiation) resets it, which is a heavy hammer but sometimes the right one for a long call that has degraded.
getStats() resolves with a Maplike object, so JSON.stringify(report) returns {} with no error — teams have shipped telemetry that faithfully transmitted empty objects for months. Then the names churn: the track stats type was removed with its fields redistributed into media-source, inbound-rtp, and outbound-rtp; sender and receiver types went away; mediaType gave way to kind and trackId to trackIdentifier. Neither failure is loud: dashboards render zero rather than erroring.
Convert with Object.fromEntries(report) or [...report.values()] and assert non-empty before sending — worth a unit test, since nothing else will catch it. Then alert on missing fields, not just bad values: a metric that goes from populated to absent should page someone. Note that webrtc-internals still displays legacy goog* names that no longer exist in the standard API.
RTCStats.timestamp is a DOMHighResTimeStamp, but what it is relative to has varied by browser and by stat type — some values look like Unix epoch milliseconds, some like time-since-time-origin. Comparing them against Date.now(), or across two participants' reports, produces nonsense offsets. Cross-participant correlation additionally suffers from ordinary wall-clock skew of seconds between devices.
Record your own performance.now() (or a server-corrected timestamp) alongside each snapshot and compute rates from that. For cross-participant timelines, have the server stamp arrival time on each beacon and store the measured client offset at join.
On a two-party call the call is cheap. On a 50-participant SFU connection there are hundreds of stats objects and a full getStats() can take 10–40 ms of main-thread time; polled every second, that is a measurable jank source in exactly the sessions that are already under load. RTCPeerConnection is not available in workers, so you cannot move it off-thread.
Poll every 2–5 s rather than every second, and use the track-selector form getStats(track) when you only need one stream. Do the aggregation in a worker by posting the plain-object snapshot, and never poll from a rAF callback.
chrome://webrtc-internals records the full event timeline — every signaling state change, every candidate pair transition, the actual SDP of every exchange — none of which is available through getStats(). Its "Create Dump" button is the only way to capture history rather than a snapshot, and enabling diagnostic packet and event recording writes an RTC event log that rtc_event_log_visualizer can render into bandwidth-estimate and pacer graphs. The page must be open before the call to capture it.
Make "open webrtc-internals in a second tab, reproduce, then Create Dump" the first line of your support playbook. For hard bandwidth-estimation bugs, the event log is the only artifact that shows the estimator's internal decisions.
When connectionState is connected and one direction has no media, it is almost always a DTLS role problem, an expired TURN permission, or a firewall that permits the flow it initiated and drops the reverse. The distinguishing observation is that transport.bytesReceived keeps rising (STUN consent checks are arriving) while inbound-rtp.packetsReceived is flat — the network path is alive and the media is not. If inbound-rtp does not exist at all, the m-line was never negotiated for receive.
Check in order: transport.dtlsState and srtpCipher present; the relayed case's 5-minute onset (TURN permission); then packet counters on both ends. Do not start with the network — start with whether RTP is being sent at all, per outbound-rtp.packetsSent on the far side.
Three distinct pathologies share the "frozen video" symptom and are separated by two counters. framesReceived rising with framesDecoded flat and keyFramesDecoded === 0 means no keyframe ever arrived. framesDecoded rising with a solid-colour render means the hardware decoder failed and did not fall back — common on Android and on some Intel iGPUs with VP9 at unusual resolutions. framesDropped rising with qualityLimitationReason: 'cpu' means the machine is out of headroom.
Log all four counters together; the triage is mechanical once you do. For suspected decoder faults, toggle chrome://flags/#disable-accelerated-video-decode to confirm, then blocklist the affected codec for that device class.
Deep packet inspection appliances that permit STUN and small UDP datagrams while dropping or rate-limiting large ones let audio (about 200-byte packets) through perfectly and destroy video, because keyframes are large and bursty. You never decode a keyframe, so video never starts at all, and the PLI loop runs forever. Because audio is flawless, users and support both conclude the problem is video-specific in your app.
Correlate the failure with network, not with device or browser: if every affected user shares an egress, it is the network. Force TURN/TLS on 443 for those users, which reframes the traffic as a single TLS stream the appliance will pass. Confirm by testing with iceTransportPolicy: 'relay' against a TLS-only TURN configuration.
The "works for everyone except one customer" class has a short suspect list: a VPN or PPPoE link with a 1400-byte MTU that fragments your larger packets, or a local security product (several consumer antivirus suites) that intercepts TLS and breaks the TURN/TLS handshake in a way indistinguishable from a firewall block. Both produce partial, intermittent, load-dependent failure that no server-side log explains.
Ask for three things before debugging code: MTU (a ping with the don't-fragment bit and decreasing sizes), whether a VPN is active, and whether any security software does HTTPS inspection. Disabling the product for a single test is a definitive answer and takes the user a minute.
RTCDataChannel.send() queues without bound and does not block. Push a 500 MB file through it in a loop and the browser buffers all of it in memory until Chrome's roughly 16 MB internal limit throws OperationError — by which point you have already ballooned the tab, and on mobile the OS may have killed it first. The channel's actual drain rate is governed by SCTP congestion control and by whatever bandwidth the media streams left over, which is often far less than you assumed.
Implement the pattern properly: set channel.bufferedAmountLowThreshold (256 KB is a reasonable value), pause when channel.bufferedAmount exceeds a high-water mark of a few hundred KB, and resume from onbufferedamountlow. Never write a send loop without a buffered-amount check in it.
The negotiated limit comes from a=max-message-size (RFC 8841) and is exposed as pc.sctp.maxMessageSize. Chrome advertises 262144 (256 KB); Firefox advertises effectively unbounded (1073741823) because it supports partial delivery. So a 1 MB message that works Firefox-to-Firefox throws when the peer is Chrome, and the interoperable safe size for arbitrary peers — including non-EOR implementations — is 16 KiB.
Chunk at 16 KiB with your own framing (sequence number plus total count) and reassemble. Read pc.sctp.maxMessageSize and use it as an upper bound rather than a target. Do not skip the framing on the assumption that both ends are the same browser.
{ ordered: false, maxRetransmits: 0 } approximates a datagram channel, but you still pay SCTP framing (a 12-byte common header plus a 16-byte DATA chunk header) and, crucially, SCTP congestion control still applies. A data channel will not exceed roughly TCP-like throughput on the path, and it shares the transport's bandwidth estimate with audio and video — a bulk transfer will visibly degrade the call it is sharing a BUNDLE with. Setting both maxRetransmits and maxPacketLifeTime throws; you get one or the other.
Rate-limit bulk data channel transfers explicitly rather than expecting congestion control to be fair to your own media. If you need real datagram semantics with independent flow control, that is what WebTransport is for.
With in-band negotiation, the answering side learns about a channel only after the SCTP association is established, so ondatachannel can fire hundreds of milliseconds after connectionState goes connected. createDataChannel(label, { negotiated: true, id: 0 }) on both sides eliminates that wait, but you must assign ids yourself and respect RFC 8832's parity rule: the DTLS client uses even stream ids, the server odd. Get it wrong and the channel silently never opens.
Use negotiated channels with explicit ids for control channels where setup latency matters, and derive parity from the DTLS role rather than hard-coding. Also create at least one data channel before the first offer if you know you will need one, so m=application is present from the start and you avoid a renegotiation.
Everyone computes the uplink: five peers at 1 Mbit/s is 5 Mbit/s up, above many residential and most mobile uplinks. The harder limit is CPU. Chrome shares an encoder across senders within one peer connection, but a mesh means five separate RTCPeerConnection objects and therefore five independent encoder instances producing five near-identical bitstreams, each with its own bandwidth estimate that competes with the other four on the same uplink. All five estimators then underestimate, so quality collapses faster than the arithmetic suggests.
Cap mesh at three or four participants and switch to an SFU above that. If you must mesh, drop to a single low resolution for everyone and consider audio-only mesh with a separate SFU for video.
When an SFU switches a subscriber from a high simulcast layer to a low one, the two layers have independent sequence number and timestamp spaces. Forward them unmodified and the receiver sees a gap or a jump, concludes packets were lost, and NACKs for packets that never existed — which the SFU cannot supply, so the receiver retries, and the jitter buffer stalls waiting for them. The symptom is a freeze on every layer switch, which correlates with network conditions and therefore looks like the network.
Rewrite SSRC, sequence numbers, timestamps, and picture ids continuously across layer switches, and switch only on a keyframe boundary of the target layer. If you are evaluating an SFU, ask specifically how it handles this — it separates the mature implementations from the demos.
One peer connection per direction gives you a single BUNDLE transport with one congestion controller managing all streams, which is what you want. Splitting subscriptions across several peer connections gives each one its own estimator, and they cannot see each other: on a constrained uplink or downlink they collectively overshoot, induce loss, all back off, and oscillate. The pattern is attractive for code organisation and it is a performance mistake.
Use one peer connection per direction (or one total) and multiplex with transceivers. If you need isolation for reliability, accept the estimator cost knowingly and cap each connection's maxBitrate so their sum stays inside a plausible total.
ontrack fires for each inbound transceiver, and event.streams[0].id is a string the SFU picked — often a UUID that means nothing to your client, sometimes absent entirely. With fifty subscriptions, the events arrive in an order you do not control and before your signaling has necessarily told you who is who. Every "wrong video in the wrong tile" bug traces to inferring identity from media rather than from signaling.
Have the SFU signal an explicit MID-to-participant mapping and key your UI on event.transceiver.mid. Render a placeholder until the mapping arrives rather than guessing, and re-read the mapping after every renegotiation because MIDs can be recycled.
Encodings are fixed when the transceiver is created: addTransceiver(track, { sendEncodings: [{ rid: 'l', scaleResolutionDownBy: 4 }, { rid: 'm', scaleResolutionDownBy: 2 }, { rid: 'h' }] }), before the first createOffer. setParameters() can change values inside existing encodings but changing their number or their rid values throws InvalidModificationError. So you cannot turn simulcast on later, and you cannot turn it on for a track added with addTrack().
Decide simulcast before you negotiate, always via addTransceiver. To disable a layer at runtime use active: false on that encoding, which is permitted and needs no renegotiation. Verify you got what you asked for by counting outbound-rtp objects.
RID simulcast (RFC 8851/8853) needs a=rid lines, an a=simulcast:send line, and the RID header extension, and support is partial everywhere: Safari does VP8 and H.264 only, Firefox has historically needed munging and has not always honoured scaleResolutionDownBy, and AV1 does no RTP simulcast at all. Hardware is the other half — many mobile SoCs support a single simultaneous encode session, so three H.264 layers becomes two, or falls back to software with an immediate qualityLimitationReason: 'cpu' on the top layer. Nothing raises an exception, so your SFU simply has no high layer for the desktop viewers who justified simulcast in the first place.
After the first negotiation, compare your requested encoding count against the number of outbound-rtp objects, and compare each layer's frameWidth against what you asked for. Report the shortfall as a fleet metric rather than a per-session error. Prefer VP8 and two layers on Android-heavy fleets, and build SFU layer selection from what exists rather than from what you configured.
setParameters({ encodings: [{ active: false }, ...] }) is the correct way to pause a layer and takes effect immediately with no renegotiation. Turning it back on requires a fresh keyframe for that layer, so subscribers switching to it see roughly a second of black or a frozen frame. Disabling the lowest layer also does not reliably hand its bitrate to the others in Chrome, so the saving is smaller than expected.
Do not toggle layers on every UI change; hysteresis of several seconds is essential. When you must re-enable, keep the subscriber on its current layer until the new one has delivered a keyframe rather than switching optimistically.
scalabilityMode on the first encoding ('L1T3', 'L3T3_KEY') is a Chrome-specific knob. VP9 and AV1 SVC send all layers on a single SSRC, so getStats() shows one outbound-rtp object with one aggregate bitrate and no way to see per-layer rates or which layers a given subscriber is receiving. The observability you had with simulcast disappears exactly when the layer logic gets more complicated.
Instrument at the SFU, which does see the dependency descriptor and can report per-layer forwarding decisions. On the client, treat SVC bitrate as a single number and rely on frameWidth/framesPerSecond on the receive side to infer which layer is arriving.
Chrome exposes RTCRtpSender.createEncodedStreams() / RTCRtpReceiver.createEncodedStreams(), which requires { encodedInsertableStreams: true } in the RTCPeerConnection constructor and runs the transform wherever you read the streams. The specification and Firefox/Safari implement RTCRtpScriptTransform, which is constructed with a Worker and assigned to sender.transform. They are not shims of each other: the worker-based API forces your crypto off the main thread and has a different event surface. Any real E2EE deployment ships both paths.
Feature-detect ('transform' in RTCRtpSender.prototype versus 'createEncodedStreams' in RTCRtpSender.prototype) and factor your frame transform so the same code runs in both. Prefer the worker path where available; it is both the spec direction and better for jank.
An SFU has to detect keyframes and, for SVC, read the dependency structure — so it needs the codec payload descriptor in the clear. Encrypt the entire frame and the SFU can no longer identify keyframes, cannot do layer selection, and cannot cache for new subscribers. The characteristic bug: E2EE works perfectly in a two-party test and video never starts through the SFU, because the SFU is waiting for a keyframe it can no longer recognise.
Leave the codec header unencrypted — the VP8 payload descriptor (1–10 bytes plus the 3-byte or 10-byte payload header on keyframes) or the H.264 NAL header — and encrypt from there. Append your IV and key generation as a trailer rather than a prefix so header offsets stay fixed. SFrame specifies this split; follow it rather than inventing your own.
Rotate a key and there are still packets in flight encrypted with the old one, plus retransmissions that can arrive seconds later, plus a jitter buffer holding frames from before the switch. A receiver that discards the old key at rotation drops those frames, which reads as a burst of loss at every rotation — and if you rotate on participant join in a busy meeting, that is constant. There is no browser-provided ratchet; the whole scheme, including the generation identifier, is yours.
Carry an explicit key generation counter in the frame trailer and keep the previous one or two generations for a window of at least 2 s. Rotate on membership change, but debounce joins so a burst of arrivals produces one rotation.
If your encoded transform function throws, or fails to call controller.enqueue(), frames simply stop being delivered. There is no error event on the sender, receiver, or peer connection; connectionState stays connected and packet counters on the far side keep rising while nothing decodes. The same silence occurs on the receive side when the first frames arrive before your key has been distributed and your transform passes ciphertext through to the decoder, which then poisons its own state.
Wrap the entire transform body in try/catch, log via postMessage from the worker, and on failure enqueue the frame unchanged rather than dropping it (or drop deliberately and count it). Before your key arrives, drop frames explicitly instead of passing them through. Remember E2EE also disables server-side recording, transcription, and any PSTN leg.
WHIP (RFC 9725) is one HTTP POST with Content-Type: application/sdp; the 201 response carries the answer in the body, the session resource in Location, and ICE server configuration in Link headers. Cross-origin, the browser will not let JavaScript read Location or Link unless the server sends Access-Control-Expose-Headers naming them. The POST succeeds, the answer applies, media may even flow — and you cannot DELETE the session or read the TURN configuration, with nothing in the console beyond a null header.
Require Access-Control-Expose-Headers: Location, Link on the WHIP endpoint and assert that response.headers.get('Location') is non-null before proceeding. Test cross-origin from the start; same-origin testing hides this entirely.
Trickle and ICE restart in WHIP are optional, done with HTTP PATCH carrying application/trickle-ice-sdpfrag (RFC 8840). A large share of deployed servers return 405 or 501. Without PATCH you must gather fully before the POST, which reintroduces the gathering-hang problem — a single unhealthy TURN entry adds ten seconds to every publish attempt, and the user experiences it as a slow, unreliable "Go Live" button.
Probe for PATCH support and fall back gracefully. In the non-trickle path, race iceGatheringState === 'complete' against a 2–3 s timeout and POST whatever candidates you have; a partial candidate list usually connects fine.
A subscriber has no local tracks, so a peer connection with nothing added produces an offer with no m-lines, and the server has nothing to answer. You must call pc.addTransceiver('video', { direction: 'recvonly' }) and the same for audio before createOffer(). Because the resulting SDP looks superficially valid, the failure presents as "the server never sends media" rather than as a malformed offer.
Create the recvonly transceivers explicitly and in the order the server expects (audio then video is the common convention). Also DELETE the WHEP/WHIP resource on unload — use fetch(url, { method: 'DELETE', keepalive: true }) in a pagehide handler, since sendBeacon cannot issue DELETE and orphaned sessions consume server slots indefinitely.
Each forwarding hop adds latency but, more importantly, each hop has its own bandwidth estimate and the downstream hop cannot slow the upstream one. When the last mile congests, the second SFU has more traffic arriving than it can deliver and its only options are to drop or to select a lower layer — it cannot ask the publisher to send less without a signalling path that most cascades do not have. So a congested subscriber degrades by loss rather than by adaptation, which is far more damaging.
Cascade only with simulcast or SVC so each hop has discrete layers to choose from, and make layer selection per-subscriber at the edge. Do not propagate REMB or TWCC-derived bitrates across hops as if they were a single path; propagate layer decisions instead.
The path is inbound-rtp → codecId → codec, and inbound-rtp → transportId → transport → selectedCandidatePairId → candidate-pair → localCandidateId/remoteCandidateId. Because BUNDLE puts every m-line on one transport, there is exactly one transport and one candidate pair for the whole session — so there is no per-stream RTT, no per-stream bandwidth estimate, and no way to say "the video path is worse than the audio path". They are the same path.
Build the join once into a helper that returns a flat per-stream record with transport fields denormalised onto it. Resist the urge to report per-stream RTT; report per-transport RTT and per-stream loss and delay, which are the things that genuinely differ.
outbound-rtp.bytesSent counts RTP payload only; headerBytesSent is a separate counter. transport.bytesSent includes RTP, RTCP, STUN, and DTLS. So the sum of your streams' bytesSent is systematically 10–20 % below the transport figure, and the gap widens with small packets (audio) and with relay framing. Many hours have been spent hunting the "missing bytes", which are headers doing their job.
Use bytesSent + headerBytesSent when comparing against transport totals or against network measurements, and use bytesSent alone when comparing against codec bitrate. Label both in your dashboards so nobody has to rediscover the difference.
codec stats objects are per-payload-type per-direction and appear only while in use, so they materialise and vanish over a call. inbound-rtp.codecId can change if the sender switches codecs, and the object it pointed at may be gone by the time you dereference it in a later snapshot. It is also the only place you learn clockRate, which you need for any timestamp arithmetic — 90000 for video, 48000 for Opus, 8000 for G.711.
Resolve codecId within the same report you read it from, never across snapshots, and carry mimeType and clockRate forward into your flattened record. Log codec changes as timeline events; an unexpected switch to a different codec mid-call is a real finding.
Counters reset when the SSRC changes (remove/add track, some ICE restarts, codec renegotiation) and when a new candidate pair is selected. The result is a negative difference between consecutive snapshots. Clamping to zero hides a real event; treating it as data produces impossible bitrates and negative loss rates that then poison every percentile you compute downstream.
Detect negative deltas explicitly, discard that window, restart the accumulator, and emit a marker so the discontinuity is visible on the timeline. Key all accumulators on the stat object's id plus ssrc so a new object cannot be mistaken for a continuation of the old one.
tc qdisc add dev eth0 root netem delay 100ms 20ms adds jitter by giving each packet an independent delay, which means packets overtake each other. Reordering is a genuinely different pathology from jitter — it triggers different NACK behaviour and different jitter buffer responses — so your "100 ms jitter" test is actually a reordering test and your results are unreproducible. Adding rate or the delay ... distribution plus a rate limit restores ordering.
Use netem delay 100ms 20ms rate 2mbit, or an explicit reorder 0%, and verify with a capture that sequence numbers arrive in order. Also remember loopback netem affects both directions, so your configured 5 % loss is closer to 10 % end to end.
Two blind spots account for most unreproducible field reports. First, iceTransportPolicy: 'relay' confirms your TURN server allocates and forwards, but it does not test the thing that breaks in production — whether ICE discovers that UDP is blocked, how long the fallback to TURN over TLS takes, and whether your certificate and proxy configuration survive it. Second, your home or office network has working multicast, so Chrome's .local candidates resolve and same-LAN peers connect directly; the customer's enterprise WLAN blocks client-to-client multicast and the identical code relays, reported to you as "it's slow and our bandwidth spiked".
Keep an environment with UDP blocked at the firewall (a container with an egress rule permitting only TCP 443 is enough) and run the full suite there — it is the highest-yield WebRTC test environment and almost nobody has one. Add a multicast-blocked case, use chrome://flags/#enable-webrtc-hide-local-ips-with-mdns to A/B whether obfuscation is implicated, and log candidateType for every production session so the next spike explains itself.
A large fraction of device-specific video bugs — green frames, garbage rows, wrong resolutions, missing simulcast layers, keyframes only on request — are hardware encoder or decoder bugs, and they are not reproducible on any other device. chrome://flags/#disable-accelerated-video-encode and #disable-accelerated-video-decode switch to the software path in one restart and answer the question definitively before you read any of your own code.
Make this step one of your device-bug playbook. If software fixes it, the remediation is a per-device-class codec blocklist or a layer-count reduction, not an application change. Record device model and GPU alongside your telemetry so the blocklist can be data-driven.
Encoder overload is recognisable without a profiler: qualityLimitationReason becomes cpu, totalEncodeTime/framesEncoded exceeds roughly 15 ms at 30 fps (you have 33 ms per frame and you need the rest for everything else), outbound-rtp.framesPerSecond falls below media-source.framesPerSecond, and qualityLimitationResolutionChanges starts climbing. On laptops it appears two to five minutes into a call as thermal throttling engages, which is why short tests never catch it.
Run at least one ten-minute soak test per release on your lowest-spec target device and graph those four values. Remediate by reducing layers, lowering maxFramerate, or switching off a software codec, in that order.
A full getStats() snapshot for a modest conference connection serialises to 30–80 KB. At 1 Hz for 10,000 concurrent participants that is on the order of gigabytes per minute of ingest, most of it identical strings and unchanging fields. Teams discover this after the first invoice, and the reflex — sample fewer sessions — biases the data toward the calls that behaved well enough to keep reporting.
Aggregate on the client: compute rates and windowed percentiles over 5–10 s, emit a fixed set of numeric fields, and send that. Keep full snapshots only for sessions flagged as bad, and send those on the failure path rather than continuously.
Periodic telemetry only reports from sessions that survived long enough to report. A call that fails ICE in four seconds, a tab that crashed, and a user who force-quit all contribute nothing, so your average quality is measured over the population that did not fail. This is why quality metrics improve after a regression that increases hard failures.
Emit a beacon on the failure paths specifically: connectionState === 'failed', iceConnectionState === 'failed', and pagehide/visibilitychange with the last snapshot in hand. Use fetch(..., { keepalive: true }) or navigator.sendBeacon. Then report a join-success rate separately from quality; conflating them hides both.
Packet loss is a poor alerting signal: NACK repairs most video loss, DTX makes audio loss percentages meaningless, and duplicates can make it negative. The metrics that correlate with complaints are (concealedSamples - silentConcealedSamples)/totalSamplesReceived for audio, qualityLimitationDurations share for video, framesDropped/framesReceived, time from join to first framesDecoded, and the share of sessions on a relay or on a TCP relay.
Define those five as your SLIs, alert on their 95th percentile rather than the mean (WebRTC quality distributions have long tails and the mean hides everything), and keep loss as a diagnostic dimension rather than an alert.
Client wall clocks differ by seconds; performance.timeOrigin differs per tab; RTCStats.timestamp epochs differ per browser. Line up two participants' timelines on their own timestamps and a causally later event appears earlier, which makes "who caused the freeze" unanswerable — and that question is the entire point of collecting both sides.
Stamp a server timestamp into the join response, measure and store each client's offset, and normalise every beacon at ingest. Carry a single call id and a per-participant id on every record so joins are cheap. Store the raw client timestamp too, so an offset bug is recoverable.
WebTransport over HTTP/3 offers datagrams and reliable streams with genuine WritableStream backpressure via writer.ready — the thing data channels lack. The costs: it is client-to-server only, so there is no P2P, and for self-signed operation serverCertificateHashes restricts you to ECDSA certificates with a validity window of at most about two weeks, which means a certificate rotation job as part of your deployment. Safari support arrived late and remains less complete than Chrome's.
Use WebTransport for client-server media or bulk data where you control the server and want proper flow control, and keep WebRTC for anything peer-to-peer or anything needing echo cancellation. Feature-detect and keep a data channel fallback if your audience includes older Safari.
Rolling your own stack gets you codec control and no SDP, and it gets you none of the parts that took a decade: no jitter buffer, no packet loss concealment, no bandwidth estimation, no pacer, no echo cancellation, no automatic gain control. Video is the easy half — a naive buffer plus dropped frames is tolerable. Audio is where these projects fail, because acceptable concealment and clock-drift-tracking time stretching is genuinely hard and users notice every millisecond of it.
If you go this route, do it for video only and keep audio on WebRTC in a parallel peer connection, or accept that you are building NetEq. Budget for congestion control too: without it you will be an unfair, loss-inducing flow on shared links.
VideoEncoder defaults to quality mode, which reorders and buffers frames and destroys interactivity; you must pass latencyMode: 'realtime' in configure(). For H.264 you must also decide avc: { format: 'annexb' } versus 'avcc', which changes whether parameter sets are inline or in a description blob — get it wrong and the decoder never initialises. And the frame pool is small: every VideoFrame and EncodedVideoChunk must be close()d or capture stalls with no error.
Configure explicitly, use encoder.encodeQueueSize as your backpressure signal, and close every frame in a finally. Test with a long-running capture; leaks present as a stall after a few seconds, not as growing memory.
If latency tolerance is above roughly two seconds, LL-HLS or LL-DASH is cheaper, simpler, and scales through CDNs you already pay for. If you are moving files, HTTP is better in every dimension. What no other browser API offers is the capture-side audio stack — echo cancellation, noise suppression, automatic gain control — plus NetEq on the receive side. That is the moat, and it is the reason "we replaced WebRTC with WebSockets and WebCodecs" projects come back for the audio.
Choose by latency budget and by whether you need microphone capture in a room with a speaker. Sub-500 ms conversational audio: WebRTC. Two-second one-to-many video: HLS. Reliable bulk data: HTTP. Mixed requirements are legitimately mixed stacks.
Two peer connections in one tab connect over loopback or a local interface, where the bandwidth estimate saturates at hundreds of megabits, RTT is under a millisecond, and there is no loss. Every adaptive behaviour you want to demonstrate — degradation preference, simulcast layer switching, concealment, jitter buffer growth — never engages. Both connections also share one audio device and one echo canceller, so an unmuted loopback demo howls.
Keep every element muted except a deliberate monitor, and add impairment to make the lab honest: iceTransportPolicy: 'relay' through a real TURN server, plus netem or Network Link Conditioner. A loopback demo with no impairment teaches the wrong intuitions.
In a same-page lab both peer connections fire onnegotiationneeded, and if both handlers offer you get a textbook glare — InvalidStateError on setRemoteDescription, and a lab that works on reload roughly half the time. Candidate cross-wiring is also easy to invert: pc1.onicecandidate must feed pc2.addIceCandidate, and the mistake produces a connection that never leaves checking with no clue as to why.
Designate one side as the offerer and give the other side no negotiation handler at all, or implement full perfect negotiation with polite/impolite roles. Name your variables so the crossing is obvious (caller/callee, not pc1/pc2), and log the direction on every candidate.
An AudioContext constructed at module load is suspended and produces nothing. A <video> without muted will not autoplay and play() rejects. Chrome also gates audio output for a tab with no user activation. In a lab page with several instruments this manifests as some panels working and others silently dead, which reads as a bug in the instruments.
One Start button that does await ctx.resume(), attaches all srcObjects, and calls play() on each element with a catch. Assert ctx.state === 'running' afterwards and surface a visible warning if it is not, rather than letting it fail quietly.
Background tabs get setTimeout and requestAnimationFrame throttled to about 1 Hz, and Chrome may freeze the tab entirely after several minutes. Your stats polling stops and your graphs flatline while the peer connection keeps running perfectly — so returning to the tab shows what looks like a total media failure that recovers the moment you look at it. On iOS the opposite happens: the connection genuinely dies while your JavaScript is suspended.
Annotate your timeline with document.visibilityState transitions and mark hidden periods as "no data" rather than plotting zeros. On resume, check connectionState and call restartIce() if needed — that single handler covers both the desktop illusion and the mobile reality.