§ 4.4  Module 4 — The Browser Gets a Dial Tone

Plan B, Unified Plan, and the ORTC Rebellion

The nice thing about standards is that you have so many to choose from.— Andrew S. Tanenbaum, Computer Networks, 1981

By the end of this lesson

  • Explain the Plan B vs Unified Plan dispute and its multi-stream trigger
  • Detect Plan B-style SDP programmatically and describe the Unified Plan equivalent
  • Recount ORTC's critique and which of its ideas survived into WebRTC 1.0
  • Debug legacy-interop failures caused by plan mismatches

Historical note

Through 2012, a WebRTC call meant one microphone and one camera, and the SDP for it was settled. The moment applications wanted more — a screen share alongside the camera, or an SFU forwarding a dozen participants' videos to each receiver — the standard had no answer to a deceptively simple question: how many m= sections does a multi-track call have? In May 2013 three competing drafts landed at the IETF's MMUSIC working group within weeks of one another. Justin Uberti of Google proposed Plan B (draft-uberti-rtcweb-plan): keep one m= section per media type and multiplex every track into it as an extra SSRC. Adam Roach of Mozilla had proposed Plan A (draft-roach-rtcweb-plan-a): one m= section per track, the way SIP hardware already read SDP. Emil Ivov of Jitsi, watching the fight, proposed NoPlan (draft-ivov-rtcweb-noplan): stop describing tracks in SDP at all and let applications signal them out of band. The compromise that emerged — Roach's structure with migration affordances, renamed Unified Plan (draft-roach-mmusic-unified-plan, July 2013) — won the working group. But Google had already shipped Plan B in Chrome, and it stayed there for six more years. Meanwhile a fourth faction gave up on SDP entirely: in August 2013 Robin Raymond of Hookflash and Bernard Aboba of Microsoft launched the W3C ORTC Community Group to replace the offer/answer machine with plain JavaScript objects, and Microsoft shipped ORTC — not WebRTC — in Edge in September 2015. The years of mismatch that followed were not academic: a Chrome-to-Firefox call could silently drop every video track after the first, with no error anywhere. This lesson is the story of that fork, and of the API you now use, which is the merger of all of it.

4.4.1The multi-stream trigger

SDP was designed in the 1990s (RFC 2327, 1998, Mark Handley and Van Jacobson) to announce multicast conference sessions, and its unit of description is the media section: a block beginning with an m= line that names a media type, a port, and a codec list. The offer/answer model (RFC 3264, June 2002, Rosenberg and Schulzrinne) layered negotiation on top: an answer must contain exactly as many m= sections as the offer, in the same order, accepting or rejecting each. For a 2011-era WebRTC call — one audio track, one video track — the mapping was trivial: one m=audio, one m=video, done.

Two use cases broke the mapping at once. First, screen sharing: a second video track in the same call. Second, and more forcefully, the SFU. As services like Google Hangouts (2013) and Jitsi Videobridge (2013) moved from mixing MCUs to forwarding SFUs — a shift Module 7 treats in depth under media topologies — each receiver suddenly needed to accept N remote video tracks, one per visible participant, and the set changed every time someone joined. SDP now had to describe a dynamic, many-track session, and RFC 3264's rigid section-matching made every track change a renegotiation. The question of where tracks live in SDP stopped being aesthetic and became the load-bearing wall of the standard.

Every RTP stream already carried a 32-bit synchronization source identifier — the SSRC, defined in RTP itself (RFC 3550, 2003) — that distinguished it from other streams on the same socket. The dispute was whether SSRCs or m= sections should be the unit that carries a track.

4.4.2Plan B: many SSRCs, one m-section

Uberti's Plan B kept exactly one m= section per media type and enumerated tracks as a=ssrc attribute lines inside it. Each SSRC line carried an msid value naming the MediaStream and track the SSRC belonged to. Two video tracks looked like this:

m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=mid:video
a=ssrc:11111 cname:host4User
a=ssrc:11111 msid:camStream camTrack
a=ssrc:22222 cname:host4User
a=ssrc:22222 msid:screenStream screenTrack

Listing 4.7 — A Plan B video section: two tracks multiplexed as two SSRCs in a single m=video block.

The virtues were real. SDP stayed small: an SFU conference with forty video tracks still produced one m=video section, and adding or removing a participant changed only a=ssrc lines, which Chrome could process without a full renegotiation round trip. For Google, whose Hangouts SFU was the largest WebRTC deployment on earth, this was the point.

The costs were structural. Everything SDP can only say per m= section — codec choice, bandwidth caps, direction (a=sendonly, a=recvonly), header extensions — became impossible to say per track. You could not send the camera in VP8 and the screen share in a codec tuned for text, could not pause one track at the SDP level, could not give the screen share its own bandwidth budget. And Plan B was unreadable to the installed base: a SIP video phone or gateway parsing RFC 3264-style SDP sees one m=video section, picks one SSRC, and silently discards the rest.

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.

4.4.3Unified Plan: one m-section per track

Unified Plan made the opposite trade: every track gets its own m= section, identified by a mid and labeled with a media-level a=msid line. The same two tracks become two sections:

m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=mid:1
a=msid:camStream camTrack
a=sendrecv

m=video 9 UDP/TLS/RTP/SAVPF 98
a=mid:2
a=msid:screenStream screenTrack
a=sendonly

Listing 4.8 — The Unified Plan equivalent: one m=video section per track, each with its own mid, codecs, and direction.

Per-track expressiveness returns immediately — the screen share above negotiates its own codec list and its own a=sendonly direction — and legacy SIP equipment can parse the result, because one-section-per-source is how pre-WebRTC SDP already worked. The obvious objection, that N tracks would demand N ports and N ICE negotiations, was answered by BUNDLE (eventually RFC 8843): all m= sections share a single transport — one ICE agent, one DTLS-SRTP session — and the mid, carried in an RTP header extension, routes packets to sections. The m= line's port number becomes ceremonial. The price is bulk: SDP for a large conference runs to hundreds of kilobytes of text, and every added track is a new section, which is why the working group's answer to scale ultimately routed around SDP growth with simulcast and SVC rather than more sections. Unified Plan was folded into JSEP, the specification of how browsers generate and consume SDP, and Mozilla shipped it first: Firefox 38 (May 2015) did multi-stream Unified Plan against Jitsi Videobridge while Chrome was still Plan B–only.

Plan B (Chrome, 2013–2019) Unified Plan (RFC 8829, 2021) m=video · a=mid:video ssrc:11111 → camTrack ssrc:22222 → screenTrack m=video · a=mid:1 a=msid: … camTrack m=video · a=mid:2 a=msid: … screenTrack 1 transport (implicit) 1 transport via BUNDLE tracks live in a=ssrc lines tracks live in m= sections
Figure 4.6 — The same two-track call in both dialects. Plan B multiplexes tracks as SSRCs inside one section; Unified Plan gives each track a section and bundles all sections onto one transport.

Interactive 3D instrument

Plan B versus Unified Plan — where a track lives in SDP

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.

4.4.4The ORTC rebellion

To one faction the Plan fight proved a deeper point: the mistake was not how to write tracks into SDP but that a text blob designed in 1998 was the API surface at all. ORTC — Object Real-Time Communications, organized as a W3C Community Group in August 2013 by Robin Raymond of Hookflash with Bernard Aboba of Microsoft as a driving editor — proposed a WebRTC with no SDP and no offer/answer state machine. Every layer the SDP blob smuggled past you became a constructible JavaScript object: an RTCIceGatherer collects candidates, an RTCIceTransport runs connectivity checks, an RTCDtlsTransport rides on it, and RTCRtpSender and RTCRtpReceiver objects are told what to send with plain dictionaries — sender.send({ encodings: [...], codecs: [...] }) — rather than by munging text. Nothing forced two ends to negotiate in lockstep; you exchanged whatever parameters you liked, however you liked. Microsoft, which had never shipped WebRTC in Internet Explorer, made ORTC its bet: Edge shipped the ORTC API in September 2015, and for several years the interoperability story between Edge and Chrome ran through adapter.js shims translating objects to SDP and back.

ORTC failed as a standalone standard — no other browser implemented it, and the network effect of a Chrome- and Firefox-compatible API was decisive — but it won as an influence, and the W3C working group absorbed its object model into WebRTC 1.0 during the 2016 restructuring of the spec. The absorption is visible every time you touch the modern API. RTCRtpSender and RTCRtpReceiver are ORTC objects, grafted onto RTCPeerConnection. The RTCRtpTransceiver you met in §4.3 is the bridge artifact itself: an ORTC-style object whose one job is to correspond to an m= section, pairing a sender and receiver under a shared mid. sender.getParameters() and setParameters() — the sanctioned way to change encodings, set bitrate caps, and control simulcast layers without renegotiating — are ORTC's parameter dictionaries verbatim. Even the transports surfaced: sender.transport is an RTCDtlsTransport, its iceTransport attribute an RTCIceTransport, and the datachannel stack exposes an RTCSctpTransport, objects you can inspect for state even though the browser, not your code, still constructs them. WebRTC 1.0, published as a W3C Recommendation on January 26, 2021, is therefore a hybrid: JSEP's SDP offer/answer engine on the outside, ORTC's object skeleton on the inside.

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.

4.4.5The migration era: sdpSemantics

Standards choose; shipped code lingers. Chrome had carried Plan B since 2013, and thousands of applications had parsed, munged, and stored Plan B SDP. Google's migration ran on a constructor flag: Chrome 69 (September 2018) added sdpSemantics so applications could opt in early, and Chrome 72 (January 2019) flipped the default.

// The migration-era escape hatches, now historical:
new RTCPeerConnection({ sdpSemantics: 'plan-b' });       // Chrome-only, removed 2021
new RTCPeerConnection({ sdpSemantics: 'unified-plan' }); // the default since Chrome 72

Listing 4.9 — The sdpSemantics flag that governed Chrome's 2018–2021 transition. It never existed in Firefox or Safari, which were Unified Plan from the start.

Plan B was removed outright in Chrome 93 (September 2021), with a temporary deprecation trial for stragglers that expired at the end of that year. The legacy pair addStream()/onaddstream — stream-oriented APIs that made sense when a stream mapped to SSRC lines — gave way to addTrack(), addTransceiver(), and the track-oriented ontrack event, which hands you the transceiver and therefore the mid.

For the engineer, the era's lasting deliverable is a diagnostic reflex, because plan-mismatch bugs had a signature unlike anything else: silent partial success. A Plan B endpoint offering two video tracks to a Unified Plan endpoint produced one m=video section; the receiver fired ontrack once and the second participant's video simply never existed — no exception, no failed state, connectionState happily connected. In the reverse direction, a Unified Plan multi-video offer hit a Plan B answerer that expected at most one video section and either rejected the extras or threw setRemoteDescription errors about the m= line count. The debugging heuristics are textual and mechanical, which is exactly why Exercise 1 has you build the detector: multiple distinct msid values inside one m= section is positive proof of Plan B; one section per track with media-level a=msid lines is Unified Plan; and a single-track-per-type call is indistinguishable — both dialects emit the same SDP, which is why simple calls interoperated for years while multiparty calls burned. One trap: multiple a=ssrc lines alone prove nothing, because Unified Plan sections legitimately carry two SSRCs for one track — the retransmission (RTX) pair declared by a=ssrc-group:FID. Count distinct tracks, not SSRCs.

A common error

“The answer can reorder m-lines to whatever is convenient.” It cannot. RFC 3264 requires the answer to contain exactly the offer's m= sections, in the offer's order, each accepted or rejected in place (a rejected section sets its port to zero, in the one place the port still means something). Unified Plan makes this rule bite constantly, because every track is an m= section: an SFU that regenerates answers from its own track list and emits them in a different order will fail setRemoteDescription with an “m-lines mismatch” error — a top-five interop bug of the migration era. The transceiver's mid exists precisely so both sides can track sections stably while the order stays frozen.

4.4.6What the fossil record teaches

Read as a whole, the 2013–2021 arc explains most of the API's apparent redundancy. Why do addTrack and addTransceiver both exist? Because one is the legacy stream-era verb and one is the ORTC-shaped section-era verb. Why does ontrack deliver streams arrays and a transceiver? Because msid keeps MediaStream grouping alive inside a per-track world. Why is there both renegotiation and setParameters? Because the working group kept JSEP's offer/answer for anything that changes the section structure, and adopted ORTC's dictionaries for anything that does not. The lesson that follows takes the remaining sharp edge of that compromise — two sides renegotiating at once, the glare problem — and shows the pattern that finally tamed it.

Checkpoint for § 4.4.5Spot the symptom

Three quick scenario reads before the anchor problems — each describes a real migration-era failure.

Check your understanding

Auto-graded check

3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m4-l4-check1)

Problems for § 4.4Check your understanding

Build the forensic tool this lesson kept promising, then face the standards history head-on.

Lab 1

The Plan Detector

Graded in the browser against 5 assertions; the editor and harness require JavaScript.

Check your understanding

Auto-graded check

5 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m4-l4-ex2)

Field quirks

What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.

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.

The Impossible Call: WebRTC from First Principles to Production · Module 4, Lesson 4