§ 2.4 Module 2 — Naming the Session: SDP, SIP, and Offer/Answer
This document defines a mechanism by which two entities can make use of the Session Description Protocol (SDP) to arrive at a common view of a multimedia session between them.— RFC 3264, June 2002
By the end of this lesson
Historical note
SDP was born declarative. Mark Handley and Van Jacobson wrote it (RFC 2327, April 1998) to announce Mbone multicast sessions — one author, many silent readers, nothing to negotiate. Then SIP (RFC 2543, March 1999) put SDP bodies inside two-party INVITEs, and the announcement format was suddenly expected to carry a conversation it had no grammar for. RFC 2543's few pages of SDP guidance left the hard questions open — what may an answer change? what must it keep? what does silence about a stream mean? — and the SIP interoperability bake-offs of 1999–2001 (the SIPit events, begun at Columbia University in April 1999) kept finding endpoints that each obeyed a private reading: answers that renumbered payload types, dropped media lines, or reordered them, every combination yielding a call that rang and then failed. The fix shipped in June 2002, when Jonathan Rosenberg (dynamicsoft) and Henning Schulzrinne (Columbia) published RFC 3264, An Offer/Answer Model with SDP, as a companion to the rewritten SIP spec (RFC 3261). It recast SDP exchange as a contract negotiation with fixed rules: I describe what I can do; you reply with the intersection of what we both can do, in my order, using my numbers, zeroing what you refuse. Nine years later WebRTC adopted the model wholesale — JSEP (RFC 8829, January 2021) specifies createOffer and createAnswer as implementations of RFC 3264 — so every negotiation bug you will ever debug in a browser is a violation of one of the 2002 rules. This lesson is those rules.
Begin with the design that was not chosen, because its failure explains everything that was. Suppose each endpoint simply sent the other its full capability description — “here is everything I can send and receive” — and each side then picked whatever it liked from the peer's list. This symmetric scheme looks simpler, and for identical twins it even works. It collapses the moment capabilities are asymmetric. If Alice supports Opus and PCMU while Bob supports PCMU and PCMA, each must independently compute the common subset and — crucially — agree on the same element of it; with no rule for whose preference order wins, Alice may encode PCMU while expecting Opus back, and Bob the reverse. Directionality is worse: “I want to send video but not receive it” and “I want to receive video but not send it” are compatible intents, but two independent declarations give no single place where the pairing is confirmed. And rejection is inexpressible: silence about a stream in a symmetric exchange could mean refusal, ignorance, or a lost packet. A negotiation needs asymmetric roles — one side proposes, the other disposes — so that exactly one message, the answer, fixes the session for both parties at once. That is the entire content of offer/answer: not a format but an algebra, in which the answer is a constrained function of the offer.
The roles come with state rules — RFC 3264 §4 — about who may send what, when. Either party may make the first offer, and either may offer again later; the roles are per-exchange, not per-call. But the exchange itself is strictly serialized. Having sent an offer, an agent must not send another until it has received the answer to the first. Having received an offer, it must not send one of its own until it has sent its answer back. At any instant, at most one offer is outstanding between the pair; each offer is terminated by exactly one answer, and only the side holding an unanswered remote offer may generate that answer. JSEP surfaces these rules as the signalingState machine of RTCPeerConnection: stable, have-local-offer, have-remote-offer — with the answer, from either side, returning the pair to stable.
stable; the rules forbid a second offer, in either direction, while one is in flight.One failure mode is built into the geometry: nothing stops both agents from leaving stable simultaneously, each sending an offer before the other's arrives. The condition is called glare, a term inherited from telephone trunk engineering, where it named two exchanges seizing the same trunk for calls in opposite directions. SIP resolves it explicitly — a UAS that receives an INVITE offer while its own is unanswered replies 491 Request Pending, and the parties retry after deliberately unequal randomized timers (RFC 3261 §14.1). WebRTC has no 491; a browser handed a remote offer in have-local-offer simply throws, and the repair — the rollback-based perfect negotiation pattern — is Module 4's business. For now, know the invariant it protects: one offer in flight, ever.
Check your understanding
Auto-graded check
3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m2-l4-check1)
Recall from Lesson 2.3 what an m= line's format list means: for a stream marked sendrecv or recvonly, the listed RTP payload types are the formats that party is willing to receive, in order of preference; the a=rtpmap: lines bind each dynamic number (96–127, per RFC 3551's registry split) to a codec name and clock rate. The offer is therefore a menu. RFC 3264 §6.1 tells the answerer what it may order: for each accepted stream, list the formats you are prepared to use that also appeared in the offer — the intersection — in your own preference order. An answer to “Opus, PCMU, or PCMA?” may be “Opus and PCMA” or “PCMA”; it may not be “G722,” however fine a codec G722 is, because the offerer never claimed to handle it.
Now the covenant that generations of implementers have broken: the answer reuses the offerer's payload-type numbers. If the offer bound Opus to 109 — Firefox's traditional choice — the answer says 109, even if the answerer, left to itself, would have said 111 as Chrome does. The numbers are arbitrary; the agreement is not. RFC 3264 makes it a SHOULD rather than a MUST, because the model technically tolerates asymmetric mappings — each party sends RTP using the numbers listed in the receiver's description, so mismatched numbering merely forces both sides to juggle two tables. But one rule in the model makes renumbering actively dangerous: the offerer must be prepared to receive media for any recvonly or sendrecv stream as soon as it sends the offer (RFC 3264 §5), because the answerer may start sending the moment the offer arrives, before its answer completes the return trip. Early media therefore reaches the offerer while the offer's numbering is the only table in the world; an answer that then renumbers invites a mid-stream reinterpretation of the payload-type field. Keeping the offerer's numbers keeps every packet unambiguous, and JSEP's createAnswer does so unconditionally.
--- OFFER (Alice: Opus, PCMU, PCMA; H.264 video) ---
v=0
o=alice 2890844526 2890844526 IN IP4 198.51.100.1
s=-
c=IN IP4 198.51.100.1
t=0 0
m=audio 49170 RTP/AVP 109 0 8
a=rtpmap:109 opus/48000/2
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=sendrecv
m=video 49172 RTP/AVP 97
a=rtpmap:97 H264/90000
a=sendrecv
--- ANSWER (Bob: Opus, PCMA; no video decoder) ---
v=0
o=bob 2890844730 2890844730 IN IP4 203.0.113.7
s=-
c=IN IP4 203.0.113.7
t=0 0
m=audio 53200 RTP/AVP 109 8
a=rtpmap:109 opus/48000/2
a=rtpmap:8 PCMA/8000
a=sendrecv
m=video 0 RTP/AVP 97
Listing 2.4 — A complete negotiation. The answer intersects the audio menu (PCMU disappears), reuses Alice's numbers (Opus stays 109), keeps both m= lines in Alice's order, and rejects video with port 0 while the grammar-mandated format token 97 rides along, ignored.
Check your understanding
Auto-graded check
3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m2-l4-check2)
RTCRtpTransceiver.direction is your local intent and is settable. currentDirection is the negotiated result and is null until an answer has been applied. Reading direction to decide whether media is flowing is one of the most common WebRTC bugs, because it reports sendrecv on a transceiver whose peer answered recvonly. Setting direction also fires onnegotiationneeded, so flipping it in a UI handler starts a renegotiation you may not have wanted.
Branch on currentDirection, and use replaceTrack(null) or track.enabled = false for mute instead of changing direction — neither triggers renegotiation.
Every media description carries a direction — a=sendrecv, a=sendonly, a=recvonly, or a=inactive — and when the attribute is absent the default is sendrecv, a default that has surprised implementers since 1998 (it is SDP's, inherited by RFC 3264 §5.1). Directions are written from the describing party's point of view, so a legal answer must mirror: if the offer says sendonly — “I will transmit and not listen” — the only compatible acceptances are recvonly (“I will listen and not transmit”) or inactive (“then let us not”). Answering sendonly to a sendonly offer describes two loudspeakers facing each other and no ears; answering sendrecv promises media the offerer has declared it will not accept. The full table is small enough to memorize. Offer sendrecv: answer anything. Offer sendonly: answer recvonly or inactive. Offer recvonly: answer sendonly or inactive. Offer inactive: answer inactive, the one case with no choice at all. In every case the answer may be “less” than the offer invited — dropping from sendrecv to recvonly is how a viewer joins a broadcast without a camera — but never “more”: an answer cannot open a direction the offer closed.
The mirror does real work in production systems. A music-on-hold implementation re-offers sendonly; a correct peer answers recvonly and mutes its own transmission without tearing anything down. A one-way surveillance feed offers sendonly and expects nothing back on the media plane except RTCP reports, which — a subtlety worth a mental bookmark — continue to flow in both directions whatever the media direction says. When you meet RTCRtpTransceiver.direction and currentDirection in Module 4, you will be looking at this table wearing a JavaScript API.
Interactive minigame
Negotiation as intersection — what an answer is allowed to say
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.
The answerer's bluntest power is refusal, and RFC 3264 §6 prescribes its exact shape: to reject a media stream, the answer keeps the stream's m= line and sets its port to zero. Keeps the line — that is the point. The answer must contain exactly as many m= lines as the offer, in exactly the offer's order; rejection is a tombstone, not a deletion. (SDP's grammar still demands at least one format token on the dead line, so the convention is to echo the offer's first, as Listing 2.4's m=video 0 RTP/AVP 97 does; the number is ignored.) The zero port is unambiguous because it could never be a real transport address — an inheritance visible even in WebRTC, where bundled media makes the port field otherwise vestigial and browsers write the placeholder port 9, the discard service, on live streams.
Why such rigidity about order? Because the m= lines are positional: neither RFC 2327's SDP nor RFC 3264 gives streams names, so the only way to say “the second stream” is to be the second stream. The offer's list and the answer's list are matched index by index — audio first with audio first, the rejected video slot with the video slot — and any permutation silently pairs one side's camera with the other side's microphone. Later extensions retrofitted names (the a=mid attribute, RFC 5888, which BUNDLE and JSEP lean on heavily), but the positional contract remains normative and remains load-bearing: in a renegotiation, a subsequent offer must carry forward every existing m= line, including port-zero tombstones, in place — a slot may be reused for a new stream, but never removed and never reordered. Media sections, like radioactive waste, are forever.
A common error
“My answerer only wants the audio, so it can just answer with the m-lines it accepts, or put its favorite first.” No. The answer's m= lines must match the offer's in number and in order (RFC 3264 §6); acceptance and rejection are expressed within each slot, never by adding, dropping, or reordering slots. This is a classic interop bug with a long rap sheet — gateways that “helpfully” pruned rejected streams broke index-matched peers throughout the 2000s, and a hand-built WebRTC answer with reordered sections will be refused by setRemoteDescription today, since JSEP matches transceivers to m= sections by index. If the order looks wrong to you, the bug is upstream: fix the generator, not the mirror.
Assemble the four rule groups and the lesson's title becomes literal. The offer proposes a set of streams, each with a menu of formats and a direction; the answer is the intersection — of codec sets, of direction intents, of willingness per stream — expressed in the offer's coordinate system: its m-line order, its payload numbers, its slots. Nothing in the answer may exceed the offer; everything in the answer must be interpretable by index against it. In the next lesson you will watch two RTCPeerConnections perform this ritual for real — and carry the descriptions between them yourself, by hand, through the signaling void.
One build and one bench check: implement RFC 3264's answer-generation rules over a plain-object model of SDP, then judge candidate answers the way setRemoteDescription would.
Lab 1
Write the Answerer
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. (m2-l4-ex2)
What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.
Rejection is expressed as m=video 0 UDP/TLS/RTP/SAVPF ... — port zero — and JSEP (RFC 8829 §5.3.1) requires that the m-line count and order be preserved forever, by both sides. So SDP only grows. A long meeting where participants repeatedly start and stop screenshare accumulates zeroed m-lines, and a browser may recycle one for a new track, which changes that m-line's MID and a=msid while leaving its index alone.
Reuse transceivers with replaceTrack() instead of add/remove cycles. If you must free an m-line, transceiver.stop() is the only API that truly retires it, and it is comparatively recent — feature-detect it. Watch your signaling message size limits: SDP over 64 KB breaks some transports.
The payload types and their preference order that govern what a sender transmits come from the answer, not the offer. Reordering m=video 9 ... 96 98 102 in your own offer changes your receive preference and nothing else, which is why "we munged the SDP to force VP9" so often has no measurable effect. Conversely, reordering in the answer you send changes what the far end sends you.
Use transceiver.setCodecPreferences() before createOffer(); in Chrome it constrains both directions for that m-line. On recent Chrome, setParameters() with encodings[].codec selects the send codec without touching the m-line at all. Verify with getStats() by joining outbound-rtp.codecId to the codec object — never by reading your own SDP.
setLocalDescription() with no argument implicitly creates the right kind of description for the current signaling state. The older createOffer() then setLocalDescription(offer) pattern opens a window in which the ICE ufrag and candidate generation have not started, application state can change, and an interleaved remote offer produces an InvalidStateError. The argumentless form also makes rollback tractable, which is why perfect negotiation depends on it.
Write await pc.setLocalDescription() and send pc.localDescription. Feature-detect for very old Safari if you still support it. If you need to inspect the offer before sending, inspect pc.localDescription after the call, not a separately created object.
The Impossible Call · Module 2, Lesson 4