§ 2.5 Module 2 — Naming the Session: SDP, SIP, and Offer/Answer
SDP was a flyer stapled to a telephone pole. Thirty years later, the flyer is a load-bearing wall.— Module 2 epigraph
By the end of this lesson
Historical note
In June 2011 Google open-sourced the media engine it had acquired with Global IP Solutions (GIPS, bought in May 2010 for roughly $68 million), and the standards work split in two: wire protocols went to the IETF's RTCWEB working group, the JavaScript API to the W3C's WebRTC working group. Then came the radical omission. Every prior real-time standard had defined call setup — H.323 (ITU-T, 1996) specified it exhaustively; SIP (RFC 2543, 1999; rewritten as RFC 3261, June 2002, by Rosenberg, Schulzrinne, and colleagues) existed for almost nothing else — yet WebRTC standardized no signaling protocol at all. An early proposal, ROAP — the RTCWeb Offer/Answer Protocol (2011) — would have baked a signaling message format into the browser; the working groups rejected it in favor of JSEP, the JavaScript Session Establishment Protocol, drafted in early 2012 by Justin Uberti (Google) and Cullen Jennings (Cisco) and finally published as RFC 8829 in January 2021. JSEP's bargain: the browser implements the media state machine and hands your application opaque session descriptions; delivering them is your job — over WebSocket, SIP, XMPP, a QR code, or, in this lesson, two variables in one page. We now exploit that omission to its logical extreme: with signaling reduced to function calls, a real, encrypted peer connection forms entirely offline.
Why would two standards bodies specify a media stack down to the bit and then refuse to say how calls are set up? Because any mandated protocol would have had to be spoken by everyone already in the room. In 2011 the room contained enterprises with a decade of SIP deployment behind RFC 3261; the XMPP world, whose Jingle extension (XEP-0166, developed from 2005 onward and used by Google Talk via the libjingle library) already negotiated peer-to-peer media; and web developers who wanted nothing more exotic than JSON over a WebSocket. Mandating SIP would have forced gateways onto XMPP shops and web startups; mandating something new would have forced gateways onto everyone. The working groups chose the null answer: the browser does media, you do rendezvous.
The API that embodies the bargain is RTCPeerConnection. Its negotiation surface — createOffer, createAnswer, setLocalDescription, setRemoteDescription — produces and consumes the offer/answer exchanges of RFC 3264 that Lesson 2.4 dissected, serialized as the SDP text of Lessons 2.1–2.2. But notice what is missing: there is no pc.send(), no address of a peer, no connect-by-URL. A description comes out of one connection as an inert string-bearing object and must arrive at the other side by whatever courier you arrange. SDP is not a protocol the browser speaks over the network; it is cargo, and you are the shipping company.
This is the property the trade press reliably garbles as "WebRTC needs no servers." What the void actually means is stranger and more useful: signaling is undefined, not unnecessary. Undefined means the courier can be anything that moves bytes — which this lesson will push to the degenerate case. If both peers live in the same JavaScript scope, the courier is a function call. No sockets, no origin server, not even a network interface doing anything but talking to itself. The connection that results is not a simulation: it is the same ICE checks, the same handshake, the same encrypted packets you will later ship across continents.
Wiring the loopback exposes two kinds of machinery this module deliberately treats as opaque. The first appears when, shortly after setLocalDescription, the connection's onicecandidate handler starts firing with objects whose candidate string looks like candidate:842163049 1 udp 2122260223 192.168.1.7 51472 typ host. These are transport candidates — concrete addresses where this peer can be reached — gathered by ICE (RFC 8445), possibly with the help of STUN and TURN servers we have not configured and, on a loopback call, do not need. Module 3 opens this box completely. For now adopt the discipline the exercises grade you on: relay whatever onicecandidate gives you to the other peer's addIceCandidate, without parsing, filtering, or ranking. One caution that trips nearly everyone: the handler eventually fires with event.candidate === null. That is not an error; it is the end-of-gathering marker. Relay real candidates, skip the null.
The second box announces itself inside the SDP as an a=fingerprint:sha-256 … line. Every WebRTC connection — including one from a page to itself — performs a full DTLS handshake and encrypts its media as SRTP, the secured profile of RTP. The mechanism, DTLS-SRTP (RFC 5763 and RFC 5764, both May 2010), derives keys from the handshake and authenticates the handshake against the fingerprint carried in signaling. There is no cleartext mode to fall back to; encryption to yourself, on your own loopback interface, is not optional. Module 4 opens this box. Today it simply explains a delay you can observe: connectionState passes through 'connecting' while the handshake runs, even with zero network distance.
navigator.mediaDevices is only defined in a secure context. localhost and 127.0.0.1 qualify; http://192.168.1.20:5173 does not, so navigator.mediaDevices is undefined and you get TypeError: Cannot read properties of undefined rather than a NotAllowedError. Everyone hits this the first time they open their dev server on a phone, and the error message points nowhere near the cause.
Serve over HTTPS for LAN testing — a Vite --https config with mkcert, a tunnel, or Chrome's --unsafely-treat-insecure-origin-as-secure=http://192.168.1.20:5173 with --user-data-dir. Guard the call: if (!navigator.mediaDevices?.getUserMedia) throw new Error('insecure context').
Here is the entire pattern, the payoff of two modules of theory. Two connections in one scope; "signaling" is four lines of event handler.
const pcA = new RTCPeerConnection(); // caller
const pcB = new RTCPeerConnection(); // callee
// The signaling plane, reduced to function calls.
pcA.onicecandidate = ({ candidate }) =>
candidate && pcB.addIceCandidate(candidate);
pcB.onicecandidate = ({ candidate }) =>
candidate && pcA.addIceCandidate(candidate);
pcB.ontrack = ({ streams }) => { remoteVideo.srcObject = streams[0]; };
for (const track of stream.getTracks()) pcA.addTrack(track, stream);
const offer = await pcA.createOffer();
await pcA.setLocalDescription(offer); // A commits to its proposal
await pcB.setRemoteDescription(offer); // B learns what A proposed
const answer = await pcB.createAnswer();
await pcB.setLocalDescription(answer); // B commits to the intersection
await pcA.setRemoteDescription(answer); // A learns the final terms
Listing 2.6 — The complete loopback call. Every WebRTC application ever written is this skeleton with a longer courier route between the two middles.
Read the six description calls as two round trips through the state machine of Lesson 2.4: each side sets its own description locally and its peer's description remotely. The classic mis-sequencing — the one the exercise harness has a dedicated hint for — is feeding a peer its own offer: pcA.setRemoteDescription(offer). The API rejects it, because an offerer in state have-local-offer can accept only an answer. The second classic is calling createAnswer before setRemoteDescription; an answer is a function of a received offer, and there is nothing yet to answer.
When the candidate relays and the handshake complete, both connections report connectionState === 'connected'. Treat that state with precise respect: it means the transport is up, not that media is flowing. A muted track, a stalled source, or a sink you never attached all leave you "connected" and black. The ground truth for flowing media is getStats: on the receiving connection, the report of type inbound-rtp carries a framesDecoded counter, and a counter that increases is the only honest definition of "video is arriving." The first exercise grades exactly that.
A common error
"WebRTC is serverless — it's peer-to-peer." This lesson is the strongest possible version of the illusion: we will complete a real call with no server of any kind. But we cheated in a way production cannot — our peers shared a JavaScript scope, so rendezvous was free. Across the real internet you always need a signaling channel you build and host (or rent), and — once NATs enter in Module 3 — STUN to discover public addresses and TURN to relay when direct paths fail; measured across real deployments, on the order of one session in ten ends up relayed. Only the media itself is ever peer-to-peer, and not always that.
Interactive lab bench
Your first peer connection — a real call with no server
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.
A call needs something to carry. The obvious source is getUserMedia, the camera-and-microphone API that predates RTCPeerConnection in shipping browsers — Opera 12 shipped it first in June 2012, Chrome 21 followed within weeks behind a webkit prefix — and that comes wrapped in a permission model for good reason: it turns a web page into a listening device. The browser prompts per origin; the user may deny, dismiss, or later revoke; since Chrome 47 (December 2015) the API is refused outright on insecure origins. Excellent policy, terrible dependency for graded work. This course's harness never calls getUserMedia in a test, because a test that fails when a laptop's camera is occupied, absent, or denied is not measuring your understanding. Live camera demos in this course are optional codas, feature-detected and never graded.
Instead we manufacture media from APIs that require no permission because they capture nothing you don't already control. For video, draw on a canvas and capture it:
// Video: a canvas repainted on a timer, captured at up to 30 fps.
const canvas = document.createElement('canvas');
canvas.width = 320; canvas.height = 240;
const ctx = canvas.getContext('2d');
let hue = 0;
setInterval(() => {
hue = (hue + 7) % 360;
ctx.fillStyle = 'hsl(' + hue + ', 80%, 50%)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, 33);
const videoStream = canvas.captureStream(30);
// Audio: an oscillator rendered into a MediaStream.
const ac = new AudioContext();
const osc = ac.createOscillator();
osc.frequency.value = 440; // concert A
const sink = ac.createMediaStreamDestination();
osc.connect(sink);
osc.start();
const audioTrack = sink.stream.getAudioTracks()[0];
Listing 2.7 — Permission-free media. The tracks these produce are ordinary MediaStreamTracks; the encoder neither knows nor cares that no camera was involved.
Two subtleties earn their margin notes. First, the frameRate argument to captureStream caps capture; it does not schedule it. If nothing repaints the canvas, no new frames exist to send, and a pixel-perfect "connected" call decodes one frame and then nothing — a bug the first exercise is designed to make you meet and fix. Second, AudioContext is subject to autoplay policy: since Chrome 66 (May 2018), a context created outside a user gesture starts suspended and must be resume()d from one. The harness runs your code from its Run button — a click — precisely so audio contexts start life running.
createOffer() serialises the peer connection's state at the moment it is called. Add a track between createOffer() and setLocalDescription() and it is absent from the offer, but onnegotiationneeded fires again, so the track appears one round trip later — or never, if your signaling only handles one exchange. The symptom is a first call where audio works and video does not, fixed by reloading, which makes it look like a race in your own code (it is, but not where you think).
Assemble all senders before you negotiate, and treat onnegotiationneeded as the single entry point to the offer flow rather than calling createOffer from UI handlers.
Function-call signaling proves the void is real; the next step proves it is useful. If signaling is any courier, then swapping couriers must not change the call code — and the cheapest courier that crosses a real process boundary is BroadcastChannel, part of the HTML Standard, shipped in Firefox 38 (May 2015), Chrome 54 (October 2016), and Safari 15.4 (March 2022). Construct a channel with a name; every other channel object with the same name, in any same-origin browsing context — another tab, another window, an iframe — receives what you post. Same-origin only, which is exactly the scope of a two-tab call to yourself.
const bc = new BroadcastChannel('room-42');
bc.postMessage({ from: myId, type: 'offer',
payload: { type: offer.type, sdp: offer.sdp } });
bc.onmessage = ({ data }) => {
if (data.from === myId) return; // never act on your own envelopes
dispatch(data);
};
Listing 2.8 — A signaling envelope. Sender identity, message type, plain-data payload: every signaling transport you ever build will re-invent this framing.
Two engineering realities separate this from the loopback. First, postMessage structured-clones its argument, and the platform objects WebRTC hands you — RTCSessionDescription, RTCIceCandidate — are not serializable; posting one throws a DataCloneError. Ship plain data: { type, sdp } for descriptions, candidate.toJSON() for candidates. Both setRemoteDescription and addIceCandidate happily accept the plain-object forms on the far side. Second, an asynchronous courier reorders your certainties: a candidate can arrive while the offer that precedes it is still inside an await setRemoteDescription(...), and addIceCandidate before a remote description is set will reject. The standard cure is a small buffer — hold early candidates in an array until the remote description lands, then flush. This is not an artifact of BroadcastChannel; every production signaling channel from WebSocket to carrier pigeon has this race, and the second exercise grades your cure for it.
What should impress you is what does not change. The connectPeers function you write for the exercise — offer out, answer back, candidates relayed, buffer flushed — contains not one line that knows whether its messages travel by function call, tab broadcast, or a WebSocket to a server in another hemisphere. (The grader negotiates a bare data channel as the cheapest thing to negotiate; media would work identically.) That indifference has a name — transport-agnosticism — and it is the entire design payload of the signaling void: JSEP fixed the algebra of negotiation and left the courier to you, so the same call code survives any rendezvous you or the next thirty years of infrastructure invent.
Two live-API exercises — the loopback call, then the same call over a real message channel — and a short quiz on what the void means.
Lab 1
The Loopback Call
Graded in the browser against 3 assertions; the editor and harness require JavaScript.
Lab 2
Bring Your Own Signaling
Graded in the browser against 3 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. (m2-l5-quiz)
What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.
Trickle ICE means candidates routinely arrive before the description they belong to, especially over a fast WebSocket. addIceCandidate() before setRemoteDescription() rejects; the spec now says implementations should queue, but relying on that across Chrome, Firefox, and Safari versions is optimistic. Because the rejection is an unhandled promise rejection inside a message handler, it is invisible, and you lose exactly the candidates that would have connected fastest.
Keep an array, push candidates while pc.remoteDescription === null, flush after setRemoteDescription() resolves, and always await pc.addIceCandidate(c).catch(...) so failures are logged rather than swallowed.
A <video> element with a remote srcObject will not autoplay without muted, and on iOS it needs playsinline or it hijacks the screen into fullscreen playback. element.play() returns a promise that rejects with NotAllowedError; unhandled, it produces nothing in the console beyond an unhandled rejection. An AudioContext constructed at page load starts in state suspended and stays there, so any Web Audio processing graph silently produces no output.
Put everything behind one user gesture: await audioContext.resume() and await videoEl.play().catch(...) in the click handler for your Join button. Set autoplay, playsinline, and initially muted on every media element, and unmute after the gesture.
The Impossible Call · Module 2, Lesson 5