Mastering the SDP Offer/Answer Lifecycle in WebRTC
The Session Description Protocol (SDP) is the declarative contract that two peers negotiate before a single media packet flows. Every codec, transport fingerprint, media direction, and BUNDLE group is encoded as text, exchanged over your signalling channel, and committed into a strict finite state machine. Get the sequence wrong and the browser throws InvalidStateError, silently drops ICE candidates, or freezes a live video track mid-call. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it covers the exact API ordering, the signalling-state transitions, and the production patterns required to negotiate and renegotiate sessions deterministically across Chrome, Firefox, and Safari.
The goal is concrete: drive a peer connection from stable to stable — through have-local-offer or have-remote-offer — without ever leaving the state machine in an undefined position, and to do the same during renegotiation while preserving active RTP streams. Everything below assumes you serialise SDP operations and never mutate description strings by hand unless a specific codec parameter forces your hand.
Step 1 — Generate and commit the offer
The lifecycle begins on the initiating peer. Call createOffer() to produce an SDP blob describing the local codec capabilities, media directions, DTLS fingerprint, and ICE ufrag/pwd. Immediately commit it with setLocalDescription() — this is the step that starts ICE gathering and locks the local media topology. Delaying the commit means candidates are produced against a description the signalling state has not yet acknowledged, and they are dropped.
// Offerer: create, commit, then transmit. Never reorder these three.
async function makeOffer(pc, signaling) {
const offer = await pc.createOffer(); // builds local SDP from transceivers
await pc.setLocalDescription(offer); // signalingState: stable -> have-local-offer, starts ICE
signaling.send({ type: 'offer', sdp: pc.localDescription.sdp }); // serialise the committed SDP
}
Pass pc.localDescription.sdp rather than the raw offer.sdp you generated — the committed description is the canonical one the browser may have normalised. Chrome and Firefox both rewrite attribute ordering during the commit, so transmitting the pre-commit string can desynchronise the two peers’ mid mapping.
The offer itself is a snapshot of the local transceivers at the moment of the call. Every track you have added via addTrack() or addTransceiver(), every direction you have set, and the codec capabilities the browser supports are serialised into m= sections, each with its own a=mid, a=rtpmap, a=fmtp, and a=setup attributes. The DTLS role (a=setup:actpass in the offer) is also fixed here — the offerer advertises that it can act as either client or server, and the answerer pins the role. The a=fingerprint line in the same block binds this description to the local DTLS certificate; checking that it matches the certificate actually presented during the handshake is the discipline described in Verifying DTLS Fingerprints to Prevent MITM. Because setLocalDescription() is what kicks off ICE, the candidates that begin streaming through onicecandidate belong to this exact description; never regenerate the offer after candidates have started without a clean rollback.
The transceiver list, not the track list, builds the m-sections
createOffer() never inspects your MediaStream objects. It walks the ordered list of RTCRtpTransceiver objects the connection holds and emits exactly one m= section per transceiver, in list order. addTrack() is a convenience wrapper over that list: it first hunts for an existing transceiver of the same kind whose sender has no track and whose direction is recvonly or inactive, attaches the track there, and only appends a new transceiver when no reusable slot exists. addTransceiver('video', { direction: 'sendonly' }) skips the search and appends deterministically. The distinction matters because the ordering it produces is permanent — once a section has been negotiated, its index and mid are frozen for the lifetime of the peer connection.
Sections are never deleted, only neutralised. Stopping a transceiver rewrites its section with a zero port (m=video 0 UDP/TLS/RTP/SAVPF 96) while keeping the a=mid line, so both peers’ section indices stay aligned; Chrome then recycles that slot for the next addTrack() of the same kind rather than appending. A client that adds and drops a screen share a dozen times therefore settles at four or five sections instead of growing without bound — but only if it calls transceiver.stop() rather than merely detaching the track.
// Pre-create the layout at construction time so mid values are deterministic on both peers.
function buildLayout(pc) {
pc.addTransceiver('audio', { direction: 'sendrecv' }); // will commit as a=mid:0
pc.addTransceiver('video', { direction: 'sendrecv' }); // will commit as a=mid:1
pc.addTransceiver('video', { direction: 'recvonly' }); // reserved slot for a later screen share
console.log(pc.getTransceivers().map(t => t.mid)); // [null, null, null] — not assigned yet
}
// mid is populated only when setLocalDescription() resolves, never at addTransceiver() time.
That last detail is the source of a whole family of race bugs: code that reads transceiver.mid immediately after addTransceiver() gets null, and code that keys an SFU routing table off it silently registers the wrong stream. Read mid only after the commit resolves. Deterministic ordering pays off on the server side too, because an SFU that maps mid to a publisher slot can parse the answer without tracking track identifiers. With a=group:BUNDLE in force the extra sections cost nothing in transport terms — every one of them shares a single ICE/DTLS transport — so the price of pre-creating slots is a few hundred bytes of SDP text on a description that is typically 3–4 KB for audio plus video, comfortably inside a single WebSocket frame.
Step 2 — Apply the remote offer and answer
The receiving peer parses the incoming offer with setRemoteDescription(), which moves its state to have-remote-offer and configures its media engine to mirror the offerer’s transceiver layout. It then calls createAnswer(), commits it locally, and transmits it back. The initiator finalises by applying that answer, returning both peers to stable.
// Answerer: apply offer, build answer, commit, transmit.
async function handleOffer(pc, offerSdp, signaling) {
await pc.setRemoteDescription({ type: 'offer', sdp: offerSdp }); // stable -> have-remote-offer
const answer = await pc.createAnswer(); // mirrors offerer's m-line order exactly
await pc.setLocalDescription(answer); // have-remote-offer -> stable
signaling.send({ type: 'answer', sdp: pc.localDescription.sdp });
}
// Offerer: finalise.
async function handleAnswer(pc, answerSdp) {
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); // have-local-offer -> stable
}
The answer’s m= line sequence must exactly mirror the offer per RFC 8843; the browser builds this automatically when you let createAnswer() run against the applied remote description. If you hand-edit the SDP between these calls you will reorder media sections and trigger the rejection patterns covered in Debugging SDP m-line Mismatches.
The answer is where the negotiation actually converges. For each media section the answerer intersects its own capabilities with the offerer’s: it picks a single codec from the offered list, settles the direction (an offered sendrecv becomes sendrecv only if the answerer also has a track to send, otherwise recvonly), and pins the DTLS role to active or passive. Once setLocalDescription(answer) resolves on the answerer and setRemoteDescription(answer) resolves on the offerer, both peers hold identical, agreed descriptions and the DTLS handshake can complete over whichever ICE candidate pair connects first. There is no separate “commit” message — the state machine returning to stable on both sides is the commit.
What the answerer is allowed to change
The answerer works within a narrow envelope, and knowing its exact width tells you which negotiation outcomes are your bug and which are the specification. It may not add, remove, or reorder sections; it may not introduce a codec the offer did not list; it may not renumber a payload type. What it may do is choose a subset, invert the direction, reject a section outright by answering with a zero port, and pin the DTLS role. Direction inversion is mechanical and worth memorising, because a track that never arrives is usually a direction that resolved differently from what you assumed.
| Offer direction | Answerer has a track to send | Answer direction | Media flow |
|---|---|---|---|
sendrecv |
yes | sendrecv |
both ways |
sendrecv |
no | recvonly |
offerer to answerer only |
sendonly |
yes | recvonly |
offerer to answerer only |
sendonly |
no | recvonly |
offerer to answerer only |
recvonly |
yes | sendonly |
answerer to offerer only |
recvonly |
no | inactive |
none — section stays negotiated |
inactive |
either | inactive |
none |
The last two rows explain a common support ticket: a section that is present, has a mid, reports no error, and carries nothing. inactive is a successfully negotiated state, so no exception fires and no ICE failure appears. Only reading the direction back off the transceiver reveals it.
Codec selection follows the same one-way rule. The answerer keeps the offerer’s payload-type numbers for whatever it accepts — a=rtpmap:111 opus/48000/2 stays 111 in the answer — and drops the rest. Browsers do this correctly; hand-rolled server-side SDP generators frequently renumber, and the peer then decodes Opus packets as whatever it mapped 111 to, producing static or silence rather than a clean failure. RTP header extensions negotiated through a=extmap intersect identically: the answerer echoes the ids it supports and omits the others, which is why an extension such as the transport-wide congestion control sequence number can disappear from a session without any visible error, taking your bandwidth estimator’s feedback with it.
An answerer that wants something the offer did not contain — an extra media section, a codec the offerer omitted, or a direction the offer forbids — cannot express it in the answer. It must complete the current exchange, return to stable, and then issue its own offer in the opposite direction. Trying to shortcut that by editing the answer is the fastest route to a permanently wedged connection.
Step 3 — Interleave Trickle ICE correctly
ICE candidates flow on a separate timeline from the SDP exchange. The instant you call setLocalDescription(), the agent begins emitting candidates through onicecandidate. Forward each one immediately — Trickle ICE cuts Time-to-First-Frame by 200–800 ms compared with waiting for iceGatheringState === 'complete', and on mobile or CGNAT paths it matters even more because STUN bindings can refresh in under 30 seconds. The ordering constraint that bites every team is on the receiving side: addIceCandidate() must not be called before setRemoteDescription() has resolved, or the candidate is silently discarded.
// Outbound: stream every candidate as it is discovered.
pc.onicecandidate = (e) => {
if (e.candidate) signaling.send({ type: 'candidate', candidate: e.candidate.toJSON() });
// e.candidate === null marks end-of-gathering; do not forward it as a candidate.
};
// Inbound: buffer until the remote description exists, then flush.
const pending = [];
function addCandidate(c) {
if (pc.remoteDescription) pc.addIceCandidate(new RTCIceCandidate(c));
else pending.push(c); // applied right after setRemoteDescription resolves
}
Your interface-filtering rules — suppressing loopback, VPN, and VM candidates — belong in the ICE Candidate Gathering & Filtering layer, and the decision of whether to trickle at all is detailed in the ICE Candidate Trickle vs Bulk Gathering deep-dive. The offer/answer state machine and ICE gathering are independent: applying an answer never restarts gathering, and an ICE restart never resets the negotiated codec set — the mechanics of issuing one on a live session are covered in Triggering an ICE Restart Without Dropping Media.
One subtlety trips up teams that route signalling over a fast transport. Because WebSocket delivery is sub-10 ms, the remote peer’s trickled candidates routinely reach you before your own setRemoteDescription() has resolved — the network is faster than the local async commit. That is why the inbound buffer above is the common path, not a defensive afterthought. The reverse also holds: your first candidates appear within a few milliseconds of setLocalDescription(), so the remote peer must already have your offer applied before they begin calling addIceCandidate(). Treat both directions as needing a buffer keyed to “has the description for this direction been applied yet,” and flush on the resolve.
End-of-gathering deserves its own handling. The null candidate is a local event, but the remote peer also benefits from knowing that no further candidates are coming: passing { candidate: '' } to addIceCandidate() signals end-of-candidates and lets the agent stop waiting on a checklist that will never grow, which shortens the time to declare failed on a genuinely unreachable path. Chrome and Firefox both accept the empty-string form; Safari accepts it from version 15 onward and ignores it harmlessly before that. Without it, a peer sitting behind a blocked relay can spend the full ICE timeout in checking rather than failing fast into your 3–5 s fallback.
The other thing that breaks this interleaving is the signalling channel itself. Candidates are fire-and-forget messages with no retransmission semantics: if the WebSocket drops for two seconds during gathering, the candidates emitted in that window are gone, and because the SDP exchange may already have completed, nothing in the state machine reports a problem — you simply end up with a smaller candidate set and, often, a relayed pair where a host pair was available. Buffer outbound candidates across a socket reconnect and replay them on the new connection, the pattern described in Reconnecting Signaling Sockets Without Losing Session State. Sequence-number each candidate message so the receiver can detect a gap rather than discovering it later as an unexplained 20–40 ms of relay latency.
Step 4 — Verification
Confirm the negotiation reached a coherent terminal state before trusting the connection. Check four signals: signalingState is back to stable; every transceiver has a stable mid; connectionState reaches connected; and getStats() reports an active, nominated candidate-pair. Poll stats at 1-second intervals rather than once — the nominated pair can change after the initial connection on flapping networks.
// Post-negotiation assertions and stats verification.
async function verify(pc) {
console.assert(pc.signalingState === 'stable', 'signalingState not stable');
pc.getTransceivers().forEach(t =>
console.assert(t.mid !== null, `transceiver without mid: ${t.receiver.track?.kind}`));
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') {
console.log(`RTC: [verify] active pair rtt=${r.currentRoundTripTime}s`);
}
}
}
In Chrome, corroborate with chrome://webrtc-internals; in Firefox, about:webrtc exposes the same offer/answer log with timestamps. A stable signalling state combined with no nominated candidate pair means signalling succeeded but ICE did not — a path problem, not an SDP problem.
This split is the single most useful diagnostic the lifecycle gives you. The two failure domains have disjoint fixes: a signalling-state fault (an InvalidStateError, a missing answer, a count mismatch) is solved in your offer/answer code, while a transport fault (no nominated pair, connectionState stuck at connecting, DTLS never completing) is solved in your ICE, STUN, or TURN configuration. Reaching stable proves the contract was agreed; reaching a nominated, succeeded candidate pair proves a path exists to honour it. Log both transitions with timestamps so that when a session fails in production you can attribute it to one domain in seconds rather than reading raw SDP.
Reading the negotiation back out of the browser
Assertions tell you whether the end state is right; the browser’s own logs tell you why it is not. Chrome’s event log records every setLocalDescription, setRemoteDescription, signalingstatechange, and icegatheringstatechange with a millisecond timestamp and the full SDP text, so diffing the offer you sent against the offer the peer applied takes seconds — and it is the only reliable way to catch a signalling layer that re-encodes, truncates, or normalises line endings in transit. The dump-reading workflow, including how to correlate the SDP log with the stats graphs on the same page, is covered in Reading chrome://webrtc-internals Dumps.
Programmatically, the transport stats report is the bridge between the SDP contract and the wire. It exposes dtlsState, selectedCandidatePairId, and the negotiated srtpCipher, which lets one poll answer all three questions at once: did negotiation converge, did a path get nominated, and did encryption come up.
// One poll that separates the signalling, ICE, and DTLS domains.
async function classify(pc) {
const stats = await pc.getStats();
const transport = [...stats.values()].find(r => r.type === 'transport');
if (!transport) return 'no transport — negotiation never produced one';
if (!transport.selectedCandidatePairId) return 'ICE: no nominated pair'; // path problem
if (transport.dtlsState !== 'connected') return `DTLS: ${transport.dtlsState}`; // cert/role problem
return `ok srtpCipher=${transport.srtpCipher}`; // contract agreed and secured
}
// Run at 1 s intervals: dtlsState can regress to 'connecting' after a role change.
A dtlsState that sticks at connecting while a pair is nominated is unambiguous — the path works and the handshake does not, which points at certificates, roles, or a middlebox rather than at anything in your offer/answer code; the triage sequence is in Debugging DTLS Handshake Failures. With max-bundle there is exactly one transport report; under max-compat there is one per media section, and checking only the first hides a half-connected session.
Edge Cases & Browser Quirks
Glare (both peers offer at once). When two peers call createOffer() simultaneously, both land in have-local-offer and reject the incoming offer with InvalidStateError. The perfect-negotiation pattern resolves this with setLocalDescription()/setRemoteDescription() rollback and a polite/impolite role. The state modelling behind it lives in the Signaling State Machine Patterns guide, and the step-by-step unwind — which peer rolls back, which one wins, and what to do with candidates already in flight — is worked through in Recovering from Glare in Offer Collisions.
Firefox a=inactive collapsing. Firefox (since ~78) collapses unused media sections to a=inactive, while Chrome preserves explicit sendrecv/recvonly directions. When a Firefox answer reaches a Chrome offerer, the direction asymmetry can surface as a frozen track. Align transceiver directions before negotiating rather than after.
Safari/WebKit codec defaults. Safari historically defaulted its first video m= line to H.264 and ordered payload types differently from Chrome’s VP8-first default. Never assume payload-type numbers are stable across engines; map by codec name via getCapabilities(). Because WebKit exposes no equivalent of chrome://webrtc-internals, confirming what Safari actually negotiated needs the remote-inspector workflow in Debugging WebRTC on Safari and iOS WKWebView.
Rollback. setLocalDescription({ type: 'rollback' }) returns a peer from have-local-offer to stable without tearing down transports. Chrome and Firefox support it; older Safari builds throw. Guard rollback behind a capability check.
Renegotiation reentrancy. A negotiationneeded event that fires while a previous offer is still in flight must be queued, not processed. Overlapping offers corrupt the state machine on every engine.
Bundled vs unbundled fallback. With bundlePolicy: 'max-bundle', all media multiplexes onto one transport, so a single rejected m= section can fail the whole negotiation. Older Safari builds and some SIP gateways still answer with max-compat, spreading sections across separate transports; the SDP then carries multiple a=candidate blocks and the verification step must check each transport, not just the first.
setRemoteDescription ordering with candidates. A burst of trickled candidates frequently arrives microseconds before the remote description resolves, especially over a sub-10 ms WebSocket signalling path. Buffering is not optional — it is the normal case, not an edge case, and a missing buffer manifests as intermittent connection failures that disappear under a debugger because the added latency masks the race.
a=extmap-allow-mixed rejection. Chrome emits a session-level a=extmap-allow-mixed attribute (permitting one- and two-byte RTP header extension headers in the same stream) in every offer since M89. Modern Firefox and Safari ignore it safely, but SIP gateways, older WebView builds, and some native SDKs parse the session block strictly and reject the entire description rather than the single unknown attribute. The symptom is a total negotiation failure with a perfectly valid-looking offer. Stripping that one line is one of the few defensible SDP edits, because it removes an attribute rather than reordering structure.
Argument-less setLocalDescription(). Passing no argument lets the browser pick offer or answer from the current signalling state and removes the window between createOffer() and the commit entirely. Chrome supports it from M80, Firefox from 75, and Safari from 15. Older Android System WebViews pinned below Chrome 80 throw a TypeError, so feature-detect by length check or wrap the call in a try/catch that falls back to the explicit two-step form.
DTLS role flips on renegotiation. A subsequent offer must keep the roles the first exchange established; per RFC 8842 an offerer that already has a DTLS association sends a=setup:actpass again but the answerer is expected to re-select the role it already holds. A server-side generator that blindly answers a=setup:active when it was previously passive forces a fresh handshake, and media stops for the 1–2 s the new association takes to complete. If renegotiation reliably produces a short audio gap, compare the a=setup lines across the two exchanges before looking anywhere else.
Track swaps that do not need negotiation at all. Replacing the source of an existing sender — a camera change, a canvas swap, switching to a screen capture on the same transceiver — goes through replaceTrack() and fires no negotiationneeded event, because the section, codec, and direction are unchanged. Teams that renegotiate for these produce a needless offer/answer round trip on every device change; the boundary between the two cases is drawn in Replacing Video Tracks Without Renegotiation.
Common Implementation Mistakes
- Reading
transceiver.midbefore the commit resolves — it isnulluntilsetLocalDescription()settles, so any routing table keyed off it registers the wrong slot. - Deleting an
m=section instead of zeroing its port — removing the block shifts every later section’s index and breaks the mid mapping on the peer. Stop the transceiver and let the browser writem=... 0. - Renumbering payload types in a server-generated answer — the answerer must echo the offerer’s numbers; renumbering decodes the wrong codec and yields static, not an error.
- Tearing down and recreating
RTCPeerConnectionto change media — this discards the DTLS session and the nominated pair, costing a full ICE and handshake cycle where a renegotiation would have cost one round trip. - Transmitting
offer.sdpinstead ofpc.localDescription.sdp— the browser normalises the committed description; sending the pre-commit copy desynchronises mid mapping. - Calling
addIceCandidate()beforesetRemoteDescription()resolves — candidates are dropped silently. Always buffer and flush. - Treating
signalingStateas synchronous — it returns tostableasynchronously aftersetLocalDescription/setRemoteDescriptionresolve, not the instant the call returns. - Hand-editing the SDP between
createAnswer()andsetLocalDescription()— this reordersm=lines and breaksa=group:BUNDLE. UsesetCodecPreferences()orsetDirection()instead. - Generating a new offer while
signalingState !== 'stable'— serialise every renegotiation through a promise queue, as shown in SDP Renegotiation Without Dropping Streams. - Forwarding the
nullcandidate as if it were a real candidate — it marks end-of-gathering only.
FAQ
Why must setLocalDescription() be called immediately after createOffer()?
The commit is what starts ICE gathering and advances signalingState. If you delay it, the agent has no committed description to gather against, so candidates are generated against stale state and dropped. Commit first, transmit second.
What is the difference between have-local-offer and have-remote-offer?
have-local-offer means this peer created and committed an offer and is waiting for the remote answer. have-remote-offer means this peer received and applied a remote offer and must now produce an answer. Both are intermediate states; the connection only resumes negotiation cleanly from stable.
Is it ever safe to edit the SDP string directly?
Rarely. Direction, codec ordering, and m-line layout should always be controlled through RTCRtpTransceiver APIs. The one defensible exception is appending a codec parameter the API does not expose — for example usedtx=1, detailed in Munging SDP to Prefer Opus DTX — and even then you must preserve BUNDLE semantics.
Does renegotiation require an ICE restart?
No. Adding tracks, switching codecs, or changing direction reuses the existing ICE transport and DTLS session. iceRestart: true is strictly for recovering a failed network path and is unrelated to the offer/answer content.
What happens to the media section when I call transceiver.stop()?
It stays in the SDP. The next offer rewrites it with a zero port and keeps its a=mid, so section indices remain aligned on both peers, and the browser may recycle the slot for the next track of the same kind. Sections are only ever neutralised, never removed — a peer connection’s section count is monotonic.
Can the answerer add a codec or a media section the offer did not contain?
No. The answer can only subset, invert direction, reject with a zero port, and pin the DTLS role. Anything additive requires waiting for stable and then sending an offer in the other direction, which is why a client that discovers it needs to send video mid-call issues its own offer rather than trying to widen the answer it is about to send.
Why does a section show no error but carry no media?
Almost always because it negotiated to inactive — typically a recvonly offer answered by a peer with no matching track. That is a successful negotiation, so no exception, no ICE failure, and no stats anomaly appears. Read transceiver.currentDirection after reaching stable rather than trusting the direction you requested.
Related: continue with the WebRTC Protocol Stack & Signaling Servers overview, then work through Debugging SDP m-line Mismatches, SDP Renegotiation Without Dropping Streams, and Munging SDP to Prefer Opus DTX.