§ 4.3 Module 4 — The Browser Gets a Dial Tone
By the end of this lesson
Historical note
RTCPeerConnection is not one design but four, stacked like strata. The oldest layer is offer/answer negotiation, codified for SIP by Jonathan Rosenberg and Henning Schulzrinne as RFC 3264 in June 2002; its vocabulary — stable, offer held locally, offer held remotely — survives verbatim in signalingState, down to the pranswer states inherited from SIP's provisional 183 responses. The next two layers came from ICE, published by Rosenberg as RFC 5245 in April 2010 after seven years of drafts and revised as RFC 8445 in July 2018: candidate gathering and connectivity checking each received a state property of its own. The fourth layer is the DTLS-SRTP transport of RFC 5763 and RFC 5764 (May 2010; Jason Fischl, Hannes Tschofenig, Eric Rescorla, David McGrew), folded into the aggregate connectionState. JSEP — begun in February 2012 by Justin Uberti of Google and Cullen Jennings of Cisco, finally published as RFC 8829 in January 2021 — bolted the machines together into one JavaScript object. The transceiver arrived years later: sketched in the ORTC counterproposal of 2013 (Bernard Aboba, Peter Thatcher, Robin Raymond), added to the W3C specification in 2016 during the Unified Plan migration, and shipped in Firefox 59 (March 2018) and Chrome 69 (September 2018). It gave the SDP m-line — until then a lump of text — an object identity.
Every RTCPeerConnection exposes four read-only state properties, each with its own change event, and each answering a different question. Confuse them and you will ship a bug; a large fraction of production WebRTC incidents reduce to code that read the wrong machine.
signalingState (event signalingstatechange) answers: where are we in the offer/answer dance? Its values are stable, have-local-offer, have-remote-offer, have-local-pranswer, have-remote-pranswer, and closed. This machine belongs to the JSEP layer and moves only when setLocalDescription or setRemoteDescription succeeds. The two pranswer states are a fossil of SIP provisional answers; you may go an entire career without seeing one fire.
iceGatheringState (event icegatheringstatechange) answers: is the ICE agent still collecting candidates? Values: new, gathering, complete. Gathering is the harvest of host addresses, of server-reflexive addresses learned from STUN, and of relayed addresses allocated on TURN servers — Module 3's material, given a property.
iceConnectionState (event iceconnectionstatechange) answers: are connectivity checks succeeding? Values: new, checking, connected, completed, failed, disconnected, closed. Note the asymmetry that trips people up: disconnected is transient — consent checks (RFC 7675) stopped getting replies, perhaps a Wi-Fi blip — and often heals itself, while failed means the agent has exhausted candidate pairs and needs an ICE restart via restartIce().
connectionState (event connectionstatechange) is the aggregate the others feed: it summarizes every underlying RTCIceTransport and RTCDtlsTransport. Values: new, connecting, connected, disconnected, failed, closed. Because it folds in the DTLS handshake, this — not iceConnectionState — is the property your call UI should trust.
Two practical rules follow. First, the properties are snapshots: reading them tells you now, and the constructor-time values (stable, new, new, new) never fire events, so an instrument attached late has already missed history. Second, the four machines are formally independent — no spec text orders iceGatheringState: complete against connectionState: connected — yet a successful call always satisfies a partial order, and learning that order is how you read logs. Listing 4.3 is the standard instrument.
const pc = new RTCPeerConnection();
const stamp = (machine, state) =>
console.log(performance.now().toFixed(0).padStart(6) + ' ' + machine + ' -> ' + state);
pc.onsignalingstatechange = () => stamp('signaling ', pc.signalingState);
pc.onicegatheringstatechange = () => stamp('gathering ', pc.iceGatheringState);
pc.oniceconnectionstatechange = () => stamp('ice ', pc.iceConnectionState);
pc.onconnectionstatechange = () => stamp('connection', pc.connectionState);
Listing 4.3 — Instrumenting all four machines. Attach before the first setLocalDescription or you will miss the opening moves.
Run Listing 4.3 on the offering side of a loopback call and a trace like Listing 4.4 appears. Every line is a lesson.
0 signaling -> have-local-offer
1 gathering -> gathering
64 signaling -> stable
65 ice -> checking
66 connection -> connecting
118 ice -> connected
131 connection -> connected
204 gathering -> complete
210 ice -> completed
Listing 4.4 — A real happy-path trace (times in milliseconds). The tail order — complete, completed, even connection: connected — varies run to run; the head order does not.
Walk it. createOffer() appears nowhere: it is a pure function that inspects the connection and mints a description, changing no state at all. The first mover is setLocalDescription(offer), which does two things at once — the signaling machine steps to have-local-offer, and the ICE agent begins gathering, so iceGatheringState steps to gathering one line later. Candidates then trickle out through onicecandidate while your signaling channel ferries the offer to the far side.
At 64 ms the answer comes back and setRemoteDescription(answer) returns the signaling machine to stable. Now the ICE agent has both local and remote candidates, so connectivity checks begin: ice: checking, and the aggregate follows with connection: connecting. The first candidate pair to pass its checks yields ice: connected; the DTLS handshake then runs over that pair, and only when it finishes does the aggregate reach connection: connected. The stragglers arrive last: the null candidate flips gathering to complete, and once checks conclude and pairs are nominated, ICE settles at completed.
The load-bearing fact is which orderings are guaranteed. Within one machine, order is total: checking precedes connected, gathering precedes complete. Across machines, only causal edges hold: checks cannot start before the remote description is applied; the aggregate cannot be connected before ICE has a working pair. But gathering may complete before or after the call connects — on a fast LAN it usually finishes last, because host candidates alone connect the call while STUN and TURN queries are still in flight. Exercise 1 has you assert exactly this partial order against a live connection.
Before the transceiver, a checkpoint on what the trace just taught.
Check your understanding
Auto-graded check
4 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m4-l3-check1)
Interactive minigame
Four state machines, one call
A 3D instrument you drive yourself, one variable at a time. It needs JavaScript and WebGL, so it is not shown in this static copy of the page.
signalingState, iceGatheringState, iceConnectionState, and connectionState are separate machines that legitimately disagree during recovery. iceConnectionState goes disconnected after roughly 5 s of failed consent checks and failed only much later; connectionState aggregates ICE and DTLS and can lag. disconnected is transient by design and recovers on its own constantly on mobile — teardown logic wired to it produces reconnect storms that are far more visible to users than the two-second gap would have been.
Drive UI from connectionState. On disconnected, show a subtle indicator and start a 3–5 s timer; only escalate to restartIce() if it has not recovered. Reserve teardown for failed and for closed.
A session description is a list of media sections — m-lines — and for WebRTC's first five years JavaScript could touch them only by string-mangling SDP. The RTCRtpTransceiver, adopted in 2016 from ORTC's object model, made the correspondence official: one transceiver per m-line, one m-line per transceiver. A transceiver bundles an RTCRtpSender (the outbound half), an RTCRtpReceiver (the inbound half), a direction, and a mid. When you inspect pc.getTransceivers(), you are reading the SDP table of contents as live objects.
There are two ways to grow the table, and choosing between them is the first API decision of any call:
addTransceiver(kindOrTrack, init) is explicit: it creates a new transceiver — a new m-line in the next offer — immediately and unconditionally, with the direction you specify (default sendrecv). It is how you say "this call has an audio slot and a video slot," even before any track exists to fill them; this is why our graded loopback tests can negotiate media without ever touching getUserMedia.
addTrack(track, ...streams) is opportunistic, its semantics written to keep pre-transceiver code working. Per the spec, it first hunts for a reusable transceiver: same kind, sender's track currently null, not stopping, and never yet negotiated to send. Only if none exists does it create one. This reuse rule is what makes the classic callee pattern work: when the offer arrives, setRemoteDescription materializes a recvonly transceiver for each offered m-line; the callee's addTrack then slots its track into that existing transceiver — flipping the direction from recvonly to sendrecv — instead of minting a redundant m-line.
The mid property is the join key between the object world and the text world. It starts as null: a freshly created transceiver is unassociated, an object with no m-line yet. The moment a description mentioning it is applied, the transceiver is associated and mid takes the value of the m-section's a=mid attribute — thereafter your stable identifier for correlating transceivers, SDP, and getStats reports.
const pc = new RTCPeerConnection();
const audio = pc.addTransceiver('audio'); // future m-section 0
const video = pc.addTransceiver('video', { direction: 'recvonly' });
console.log(audio.mid, video.mid); // null null — unassociated
await pc.setLocalDescription(); // implicit createOffer (Chrome 80, Feb 2020)
console.log(audio.mid, video.mid); // "0" "1" — bound to a=mid lines
Listing 4.5 — Transceivers exist before their m-lines do; association assigns mid.
Transceivers end, but m-lines are forever — almost. Calling transceiver.stop() puts it in a stopping state and fires negotiationneeded; after the next exchange the m-section is marked rejected with port zero. SDP's offer/answer rules forbid ever deleting an m-line or reordering them (the answer must mirror the offer's m-line order), so JSEP (RFC 8829 §5.2.2) instead lets a later addTransceiver recycle the dead slot: the new transceiver takes over the rejected m-section's position, under a fresh mid. Without recycling, a long-lived conference where participants join and leave for hours would accrete an SDP of hundreds of zombie m-lines, reparsed on every renegotiation.
A transceiver carries two direction properties, and the distance between them is negotiation itself. direction is intent: what your application wants, writable at any time, one of sendrecv, sendonly, recvonly, inactive. currentDirection is fact: what the last completed offer/answer actually agreed to, read-only, and null until a first negotiation has concluded — the single most common source of "why is my direction null?" questions.
The two diverge because the far side gets a vote. Offer sendrecv to a peer that only wants to receive and the answer comes back recvonly; the negotiated result — your currentDirection — is sendonly, the intersection of the two intents seen from your side. The direction attributes in SDP are always written from the describer's perspective, which is why reading raw SDP requires knowing whose description you hold.
Writing direction is also how you script a hold. This is old telephony choreography: RFC 3264 §8.4 specified hold as re-offering a=sendonly (you still hear music on hold) or a=inactive (nothing flows either way), and WebRTC inherited the recipe unchanged. Set transceiver.direction = 'inactive' and — because intent now differs from the negotiated state — the browser fires negotiationneeded. Run your offer/answer dance, and currentDirection becomes inactive: held. Set it back to sendrecv, negotiate again: resumed. Note that negotiationneeded fires only while signalingState is stable, and multiple synchronous changes coalesce into a single firing — the event means "the current descriptions no longer describe what you want," not "a method was called." Driving renegotiation correctly off this event, including when both sides fire it at once, is the subject of perfect negotiation.
pc.close() deliberately dispatches no state-change events, so your onconnectionstatechange handler never sees closed and any cleanup hanging off it does not run. It also does not stop the local tracks, so the camera light stays on. And getStats() after close resolves with an empty report in Chrome and rejects with InvalidStateError in Firefox, which means the final snapshot you wanted for your call-quality beacon is gone.
Snapshot getStats() and post your beacon before calling close(). Do your own cleanup synchronously around the close call: stop every local track, null out srcObject, clear intervals, and update UI state explicitly.
Now the trick that makes camera switching instant. The SDP for a video m-section describes capabilities and direction — codecs, header extensions, sendrecv — but never mentions which track feeds the encoder. So swapping the source does not change the negotiated contract, and the API honors that: sender.replaceTrack(newTrack) resolves without touching SDP, without firing negotiationneeded, without a signaling round trip. The only requirements are that the transceiver is live and the kinds match — replacing video with audio rejects with a TypeError. Passing null is legal and simply stops sending, a lighter-weight pause than a direction change because the far side is never consulted.
// front camera -> screen share, mid-call, zero signaling:
await sender.replaceTrack(screenTrack);
// pause sending without renegotiation:
await sender.replaceTrack(null);
// contrast: this DOES fire negotiationneeded (new m-line or reused slot)
pc.addTrack(anotherTrack, stream);
Listing 4.6 — replaceTrack rides the existing negotiated contract; addTrack amends the contract and must renegotiate.
A common error
"Renegotiation tears down the connection, or at least restarts ICE." It does neither. An in-call offer/answer exchange updates descriptions over the existing DTLS transport; ICE restarts happen only when you explicitly request one (restartIce(), or the legacy iceRestart: true offer option). The cost of renegotiation is a signaling round trip and a glare hazard — not a reconnection. And for the most frequent mid-call change, swapping a source, replaceTrack avoids even that: no new offer, no new answer, no risk. Reserve renegotiation for changes SDP actually records: adding m-lines, changing directions, changing codecs.
One last calibration of trust. connectionState === 'connected' asserts exactly this: ICE found a working candidate pair, and the DTLS handshake completed. You have a verified, encrypted pipe. It asserts nothing about RTP flowing through that pipe, and nothing whatsoever about frames being decoded. Every black-video bug you will ever debug lives in that gap: the transceiver negotiated recvonly when you meant sendrecv; a replaceTrack(null) that was never undone; a track that is muted because the camera was unplugged; an encoder starved by the congestion controller; a lost keyframe leaving the decoder waiting. In every one of those failures, all four state machines of this lesson report sunshine.
The honest instrument is getStats. On the receiving side, the inbound-rtp report of kind video carries framesDecoded, framesPerSecond, and bytesReceived; on the sending side, outbound-rtp carries framesEncoded and bytesSent. State machines tell you a pipe exists; only counters that increase between two samples tell you media moves through it. That sampling discipline — read twice, assert the delta — is exactly how Exercise 2 grades your track swap, and it is how your production health checks should work too. When a user reports black video, the diagnostic order is fixed: connectionState first (is there a pipe?), currentDirection second (did we agree to send?), framesDecoded delta third (do frames actually arrive?). Module 6 builds this into a full forensic method.
Two live-API labs: instrument all four state machines on a real loopback call, then script a full hold–resume–swap lifecycle and prove replaceTrack never renegotiates.
Lab 1
The state interleaving logger
Graded in the browser against 4 assertions; the editor and harness require JavaScript.
Lab 2
Hold, resume, swap
Graded in the browser against 4 assertions; the editor and harness require JavaScript.
What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.
signalingState includes have-local-pranswer and have-remote-pranswer, and you can construct a description with type: 'pranswer'. Nothing consumes it: there is no early-media use case implemented in browsers, and a provisional answer buys you nothing over just applying the real answer. Code paths written to handle all five signaling states are handling two that will never occur, which is harmless but obscures the two-state reality that matters (stable versus not).
Write your negotiation logic against stable, have-local-offer, and have-remote-offer only, and assert on anything else so a genuine surprise is loud.
removeTrack() sets the transceiver's direction to recvonly or inactive and leaves it — and its m-line — in place forever. getTransceivers() returns insertion order before the first negotiation and m-line order afterwards, so an index you cached across a renegotiation now points somewhere else. transceiver.stop() is the only API that retires one (setting currentDirection to stopped and zeroing the port), and it shipped late enough that you must feature-detect it.
Hold references to transceiver objects, never indices, and re-derive the mapping from mid after each renegotiation. For repeated add/remove patterns (screenshare toggling), keep one transceiver and use replaceTrack().
The Impossible Call · Module 4, Lesson 3