Field quirks

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.

§ 1.1 The Tyranny of the Circuit

G.114's 150 ms is one-way; every stat you have is round-tripperf

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.

What to do

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.

Nothing you compute is a MOSperf

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.

What to do

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 intuition is the wrong capacity model for packet mediadeployment

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".

What to do

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.

§ 1.2 Sound into Numbers: Sampling, PCM, and G.711

You do not choose the sample ratebrowser

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.

What to do

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.

Float32 audio is not clamped, so naive int16 conversion wrapsapi

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.

What to do

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.

PCMU/PCMA clock at 8 kHz, so the RTP timestamp steps 160 per framespec

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.

What to do

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.

64 kbit/s of G.711 costs about 87 kbit/s on the wireperf

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.

What to do

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.

§ 1.3 Best-Effort and Its Discontents: Why TCP Can't Carry a Call

UDP is not actually unblockeddeployment

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.

What to do

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.

BUNDLE reintroduces head-of-line coupling that UDP removedspec

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.

What to do

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.

TURN over TCP gives you head-of-line blocking back, in fulldeployment

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.

What to do

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.

Path MTU is not 1500, and one lost fragment kills the datagramdeployment

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".

What to do

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.

§ 1.4 RTP: A Clock in Every Packet

Audio and video RTP timestamps live in unrelated clocksspec

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.

What to do

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.

A video frame is many packets with the same timestampspec

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.

What to do

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.

SSRC changes reset every cumulative counterapi

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.

What to do

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.

RTX and FEC have their own SSRCs, so naive summing double-countsapi

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.

What to do

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.

§ 1.5 RTCP: The Feedback Channel

packetsLost is signed and goes negativeapi

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.

What to do

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 %.

roundTripTime only exists after an RTCP report arrivesapi

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.

What to do

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.

jitter is in seconds, and it is a laggy EWMAapi

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.

What to do

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.

rtcp-mux is mandatory, and it removes your ability to shape RTCPdeployment

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.

What to do

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.

§ 2.1 Flyers on a Telephone Pole: The Mbone and SAP

SDP is an announcement format with negotiation bolted onspec

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.

What to do

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.

The o= line contains nothing you can useapi

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.

What to do

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.

SDP requires CRLF, and your munger is stripping the CRapi

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.

What to do

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.

There is no capability discovery in SDPapi

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.

What to do

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.

§ 2.2 The Signaling Wars: H.323 vs SIP

SIP SDP is not WebRTC-legal SDPdeployment

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.

What to do

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.

SIP hold arrives as c=IN IP4 0.0.0.0 and breaks ICE, not mediadeployment

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.

What to do

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.

Legacy MCUs offer H.264 packetization-mode=0, and Chrome accepts itdeployment

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.

What to do

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.

DTMF requires a negotiated telephone-event and fails silently without itapi

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.

What to do

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.

§ 2.3 SDP Anatomy, Line by Line

Session-level and media-level attributes both exist, and media winsspec

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.

What to do

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 is kilobits, setParameters is bits, and one of them will bite youapi

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.

What to do

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.

The c= line is vestigialapi

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.

What to do

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.

MID values are opaque strings, and they get recycledbrowser

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.

What to do

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.

§ 2.4 Offer/Answer: Negotiation as Intersection

direction is what you want; currentDirection is what you gotapi

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.

What to do

Branch on currentDirection, and use replaceTrack(null) or track.enabled = false for mute instead of changing direction — neither triggers renegotiation.

Rejected m-lines never leave the SDPspec

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.

What to do

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.

Munging codec order in the offer does not change what you sendapi

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.

What to do

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.

Call setLocalDescription with no argumentsapi

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.

What to do

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.

§ 2.5 The Signaling Void: Your First Peer Connection

getUserMedia on a LAN IP gives you a TypeError, not a permission errorbrowser

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.

What to do

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').

The offer is a snapshot; tracks added after createOffer are not in itapi

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).

What to do

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.

Buffer remote candidates until the remote description is setapi

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.

What to do

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.

Autoplay policy kills your first call, quietlybrowser

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.

What to do

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.

§ 3.1 How the Internet Ran Out of Addresses

A server-reflexive candidate is not proof of a public addressdeployment

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.

What to do

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'.

NAT mappings expire faster than the RFC says, and mobile suspends your timersdeployment

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.

What to do

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.

mDNS obfuscation plus blocked multicast makes same-LAN peers relaybrowser

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.

What to do

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.

IPv6-only and NAT64 networks break TURN configured by IP literaldeployment

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.

What to do

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.

§ 3.2 STUN: Asking a Stranger for Your Address

SIP ALGs on consumer routers still mangle STUN and SDPdeployment

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.

What to do

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.

Browsers do not gather reflexive candidates over TCPbrowser

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.

What to do

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.

More STUN servers makes ICE slower, not more reliableperf

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.

What to do

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.

ICE-STUN is not plain STUN, and unknown-attribute handling mattersspec

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.

What to do

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.

§ 3.3 TURN: The Relay of Last Resort

Static TURN credentials in JavaScript become an open relaysecurity

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.

What to do

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.

Only TURNS over TCP 443 reliably crosses corporate egressdeployment

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.

What to do

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.

Allocations, permissions, and channels all expire on different clocksdeployment

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.

What to do

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.

Relay doubles your egress and shrinks your effective MTUperf

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.

What to do

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.

§ 3.4 ICE: The State Machine That Saved P2P

Peer-reflexive outranks server-reflexive in the priority formulaspec

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.

What to do

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.

The selected pair changes mid-call and resets per-pair countersapi

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.

What to do

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.

Browsers only gather active TCP candidates, so direct TCP P2P is impossiblebrowser

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.

What to do

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.

Nomination style differs between implementations and flips your selected pairspec

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.

What to do

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.

§ 3.5 Trickle, Restarts, and Real-World Traversal

End-of-candidates is a null candidate, and forgetting it delays failure detectionapi

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.

What to do

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.

Firefox does not gather new interfaces after gathering completesbrowser

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.

What to do

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.

ICE restart does not re-key DTLS, but it does churn your stats objectsapi

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.

What to do

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.

A dead TURN server makes gathering hang, so never wait for completedeployment

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.

What to do

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.

§ 4.1 The Plugin Decade and the $68M Bet

SDP munging is not a supported extension point, and Chrome now enforces thatapi

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.

What to do

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.

Every major browser runs libwebrtc, which means shared bugs and divergent edgesbrowser

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.

What to do

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.

There is no "WebRTC 1.0" you can target; feature-detect per methodapi

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.

What to do

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.

§ 4.2 Capturing the World: getUserMedia and Tracks

Bare constraint values are ideal, and ideal means "or whatever"api

{ 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".

What to do

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.

enumerateDevices before permission returns unusable entriesbrowser

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.

What to do

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.

iOS allows one capture session, and a second getUserMedia kills the firstbrowser

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.

What to do

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.

Muting by disabling a track still sends packets; stopping it freezes the far endapi

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.

What to do

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.

§ 4.3 RTCPeerConnection In Depth: Four State Machines and the Transceiver

Four state machines, and disconnected is not a failureapi

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.

What to do

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.

close() fires no events and takes your stats with itapi

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.

What to do

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.

pranswer exists in the state machine and nowhere elsespec

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).

What to do

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.

Transceivers are append-only, and stop() is the only real deleteapi

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.

What to do

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().

§ 4.4 Plan B, Unified Plan, and the ORTC Rebellion

sdpSemantics: plan-b is gone and silently ignoredbrowser

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.

What to do

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 chooses the transceiver for youapi

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.

What to do

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.

addTrack without a stream produces a=msid:- and an empty event.streamsbrowser

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.

What to do

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.

SDP grows monotonically and eventually breaks your signaling transportperf

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.

What to do

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.

§ 4.5 Perfect Negotiation: Taming Glare

onnegotiationneeded fires late, more than once, and sometimes spuriouslyapi

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.

What to do

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.

Rollback is real, recent, and does not undo your track changesapi

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.

What to do

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.

Ignored offers generate addIceCandidate errors you must swallowapi

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.

What to do

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 avoids glare but can green-frame the remote decoderbrowser

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.

What to do

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.

§ 4.6 Securing the Call: How DTLS-SRTP Won

A munged a=setup deadlocks DTLS while ICE reports successsecurity

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.

What to do

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.

DTLS-SRTP authenticates the signaling channel you already trustedsecurity

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.

What to do

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.

Certificate generation is slow on mobile, and reusing one is a tracking vectorperf

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.

What to do

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.

SDES is gone, and the SRTP cipher you get is worth checkingsecurity

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.

What to do

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.

§ 5.1 Why Raw Media Is Impossible: Perceptual Coding

Raw 1080p30 is 750 Mbit/s, and every JavaScript copy costs 3 MBperf

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.

What to do

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.

contentHint is the difference between readable and unreadable screenshareapi

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.

What to do

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.

Transcoding compounds; generation loss is why SFUs beat MCUsperf

"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".

What to do

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.

§ 5.2 Opus: The Peace Treaty

Opus fmtp parameters constrain what the peer sends you, not what you sendspec

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.

What to do

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.

DTX makes your loss math lieperf

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.

What to do

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.

Opus in-band FEC is reactive, partial, and not freespec

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.

What to do

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.

Larger ptime buys packet-rate relief and pays in latencyperf

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.

What to do

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.

§ 5.3 Seeing in Numbers: Color Spaces and Chroma Subsampling

YUV to RGB is not defined identically across browsersbrowser

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.

What to do

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.

Everything is 4:2:0, and red text is the worst casespec

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.

What to do

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.

Odd dimensions get silently rounded, and some encoders want multiples of 16browser

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.

What to do

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.

Capturing an HDR or wide-gamut display gives you tone-mapped 8-bit SDRbrowser

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.

What to do

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.

§ 5.4 The Grammar of Video: Frames, GOPs, and Rate Control

There are no B-frames, and there is no GOPspec

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.

What to do

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.

A subscriber with no keyframe shows black while packets pour indeployment

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.

What to do

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.

You do not set the bitrate; you set a ceiling, and degradationPreference decides what suffersapi

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.

What to do

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.

qualityLimitationReason answers "why is my video bad" and nobody reads itapi

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.

What to do

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.

§ 5.5 The Video Codec Wars: H.264, VP8, VP9, AV1

Codec capability is per-device and per-direction, and setCodecPreferences cannot invent oneapi

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).

What to do

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.

Hardware encoders ignore your bitrate, need a nudge for keyframes, and come one to a devicebrowser

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.

What to do

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.

AV1 means software encoding and SVC, not simulcastperf

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.

What to do

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.

Payload type numbers are dynamic and stable across nothingapi

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.

What to do

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.

§ 5.6 The Voice Pipeline: GIPS's Crown Jewels

AEC only cancels audio the browser itself playsbrowser

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.

What to do

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.

applyConstraints on audio processing succeeds and does nothing in Safaribrowser

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.

What to do

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.

audioLevel from getStats reports concealed audio, so it is a bad VADapi

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.

What to do

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.

Concealment is the real audio quality metric, and it is invisible by defaultapi

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.

What to do

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.

§ 6.1 Losing Packets Gracefully: NACK, RTX, FEC, and Keyframe Requests

RTX inflates the bitrate you think you are sendingapi

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.

What to do

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.

Audio has no retransmission in practice; it is concealed, not repairedspec

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.

What to do

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 is not on; what you have is NACK for video and RED for audiobrowser

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.

What to do

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.

A PLI storm can collapse a conferenceperf

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.

What to do

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.

§ 6.2 Guessing the Pipe: Bandwidth Estimation and GCC

TWCC and REMB put the estimator on opposite ends of the callspec

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.

What to do

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.

availableOutgoingBitrate is on candidate-pair, not on outbound-rtpapi

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.

What to do

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.

Wi-Fi frame aggregation looks exactly like congestion to a delay-gradient estimatorperf

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.

What to do

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.

Startup is slow by design; x-google-start-bitrate is the only lever, in kilobitsapi

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.

What to do

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.

§ 6.3 The Jitter Buffer: Trading Delay for Smoothness

playoutDelayHint and jitterBufferTarget are the only jitter buffer knobs, and both are Chrome-onlybrowser

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.

What to do

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.

NetEq time-stretches audio, and the counters reveal clock driftapi

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.

What to do

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.

Every jitter buffer stat is a cumulative pair you must divideapi

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.

What to do

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.

One stall raises the buffer for the rest of the callperf

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.

What to do

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.

§ 6.4 Reading the Instruments: getStats()

A stats pipeline fails silently twice: a Map that stringifies to {}, and names that vanishapi

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.

What to do

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.

Timestamps have different epochs; use your own clockbrowser

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.

What to do

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.

getStats() is main-thread work and scales with receiversperf

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.

What to do

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.

webrtc-internals holds information getStats cannot give youdeployment

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.

What to do

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.

§ 6.5 Field Pathologies: The Greatest Hits of Production Misery

One-way media with ICE connected has three usual causesdeployment

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.

What to do

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.

Frozen video with rising packet counts is a frame problem, not a network problemdeployment

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.

What to do

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.

Audio fine, video never starts, only on corporate networksdeployment

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.

What to do

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 single-user mystery is usually MTU or a TLS-inspecting antivirusdeployment

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.

What to do

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.

§ 7.1 Data Channels: SCTP's Second Life

bufferedAmount is the only backpressure, and send() will not stop youapi

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.

What to do

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.

maxMessageSize differs by an order of magnitude between browsersbrowser

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.

What to do

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.

Unreliable mode is still SCTP, and it competes with your mediaperf

{ 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.

What to do

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.

Negotiated channels skip a round trip; id parity is not yours to chooseapi

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.

What to do

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.

§ 7.2 Mesh, MCU, SFU: The Topology Wars

Mesh dies from encoder instances, not from bandwidthperf

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.

What to do

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.

An SFU that does not rewrite sequence numbers triggers NACK stormsdeployment

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.

What to do

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.

Multiple peer connections to the same SFU fight each otherperf

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.

What to do

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.

With many inbound streams, stream and track ids are whatever the SFU choseapi

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.

What to do

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.

§ 7.3 Simulcast and SVC: One Stream, Many Screens

Simulcast must be declared at transceiver creation; setParameters cannot add layersapi

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().

What to do

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.

You get fewer layers than you asked for, and nothing throwsbrowser

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.

What to do

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.

Re-enabling a layer costs a keyframe, which costs a visible gapperf

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.

What to do

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.

SVC hides its layers from getStatsapi

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.

What to do

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.

§ 7.4 Trusting the Middleman: E2EE with Insertable Streams and SFrame

Insertable streams shipped as two incompatible APIsbrowser

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.

What to do

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.

Encrypt the payload but leave the codec header readable, or the SFU goes blindsecurity

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.

What to do

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.

Key rotation must tolerate old packets arriving after the new keysecurity

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.

What to do

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.

A transform that throws stops media with no error eventapi

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.

What to do

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.

§ 7.5 Broadcast Convergence: WHIP, WHEP, and Cascading SFUs

WHIP depends on CORS headers most servers forget to exposedeployment

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.

What to do

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.

Most WHIP servers do not implement PATCH, so you are back to non-trickledeployment

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.

What to do

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.

WHEP needs recvonly transceivers before createOffer, or you offer nothingapi

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.

What to do

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.

Cascaded SFUs put two congestion controllers in seriesperf

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.

What to do

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.

§ 8.1 Stats-Graph Forensics: The Full Join

The stats graph is a join, and BUNDLE collapses per-stream transport dataapi

The path is inbound-rtpcodecIdcodec, and inbound-rtptransportIdtransportselectedCandidatePairIdcandidate-pairlocalCandidateId/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.

What to do

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.

bytesSent excludes headers, transport bytes include everythingapi

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.

What to do

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.

codecId changes mid-call and codec objects come and goapi

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.

What to do

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.

A negative delta is an epoch boundary, not dataapi

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.

What to do

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.

§ 8.2 The Failure Catalog: Reproduce, Recognize, Fix

netem with jitter reorders packets unless you tell it not todeployment

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.

What to do

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.

Your test network is too well behaved to reproduce the bugdeployment

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".

What to do

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.

Toggle hardware codecs first; it is the fastest bisect in WebRTCbrowser

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.

What to do

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.

The CPU cliff has a numeric signatureperf

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.

What to do

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.

§ 8.3 Observability at Scale: The Call-Quality Pipeline

Raw stats per second per participant does not fit in your budgetperf

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.

What to do

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.

Your dashboards systematically exclude the worst callsdeployment

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.

What to do

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.

Alert on the metrics that map to user experience, not on lossperf

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.

What to do

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.

Cross-participant correlation needs a server clockdeployment

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.

What to do

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.

§ 8.4 The Unbundling: WebCodecs, WebTransport, and When Not to Use WebRTC

WebTransport gives real backpressure but no peer-to-peerapi

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.

What to do

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.

WebCodecs plus WebTransport means reimplementing NetEq and GCCperf

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.

What to do

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 needs latencyMode realtime and disciplined close()api

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.

What to do

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.

The strongest reason to use WebRTC is the audio pipeline, not the transportdeployment

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.

What to do

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.

§ 8.5 Capstone Lab: The Full Stack in One Page

Same-tab loopback is not a realistic network and lies about everythingdeployment

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.

What to do

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.

Wire negotiation on one side only, or you glare with yourselfapi

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.

What to do

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.

Everything audible or animated needs one user gesture firstbrowser

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.

What to do

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.

A backgrounded tab freezes your instruments but not the connectionbrowser

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.

What to do

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.