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. 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.
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.
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.
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.
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.
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 full recovery sequence is covered in the Signaling State Machine Patterns guide.
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().
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.
Common Implementation Mistakes
- 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.
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.