Handling SDP Renegotiation in WebRTC Without Dropping Streams
Mid-call changes β adding a screen-share track, switching a codec, flipping a transceiver to receive-only β re-trigger the offer/answer exchange on a connection that is already carrying live media. Done wrong, the renegotiation throws InvalidStateError or freezes an active video track while the remote decoder reinitialises. This guide is part of the SDP Offer/Answer Lifecycle guide, and it solves one precise problem: how to serialise renegotiation so the signalling state machine never overlaps offers, and how to mutate transceivers so existing RTP streams stay attached throughout.
Context & Trade-offs
Renegotiation reuses the existing ICE transport and DTLS session β it never needs an ICE restart β so the only real risk is the signalling state machine. Track additions fire negotiationneeded automatically, and if two of those fire close together (a common pattern when an app adds audio and video in the same tick), the second createOffer() can run while the first offer is still in have-local-offer, corrupting state on every engine.
The trade-off is between a queue and raw event handling. A promise-chained queue adds a few milliseconds of latency between the trigger and the offer but eliminates the entire overlap failure class; raw handling is marginally faster but unsafe under burst track changes. Always choose the queue. The second trade-off is in how you stop a track: calling removeTrack() destroys the SSRC mapping and can make the remote decoder drop the stream abruptly, whereas setting RTCRtpTransceiver.direction to 'inactive' or 'recvonly' stops media flow while preserving the mapping, so the remote side keeps the decoder warm and resumes instantly when you reactivate. That distinction is the same one analysed in Muting Tracks vs Stopping Them, and it is worth checking first whether you need renegotiation at all: swapping a camera or a screen-share source on an existing sender is a pure Replacing Video Tracks Without Renegotiation operation that never touches the SDP. The cost is a slightly larger SDP that retains the dormant section; the benefit is zero-glitch resumption, which is almost always worth it.
There is also a subtler hazard called glare: if both peers trigger negotiationneeded and offer at nearly the same instant β common when an app and its remote counterpart both add a screen-share track on a shared event β each lands in have-local-offer and rejects the otherβs offer with InvalidStateError. A local queue serialises your offers but does nothing about the remote peerβs. For a connection that can renegotiate from either side, layer the perfect-negotiation pattern (polite/impolite roles with rollback) on top of the queue; the recovery mechanics are detailed in the Signaling State Machine Patterns guide, and the rollback sequence itself is walked through step by step in Recovering from Glare in Offer Collisions. When only one side ever initiates renegotiation β the typical client-to-SFU topology β the queue alone is sufficient and glare cannot occur.
Why the transceiver, not the track, is the unit of negotiation
Each RTCRtpTransceiver is bound to exactly one m-section by a mid the browser assigns the first time setLocalDescription() runs and never reassigns for the life of the connection. A renegotiation rewrites the contents of that m-section β direction attribute, payload-type list, header extensions, bandwidth lines β while leaving the transceiver object, its sender, the encoder instance behind that sender, and the SSRC it emits untouched. That is the mechanical reason a direction flip is nearly free: the browser diffs the new description against the applied one section by section and reconfigures only what differs. Keep the negotiated codecβs payload type in the new offer and the encoder is reconfigured in place; change the negotiated codec and the encoder is torn down and rebuilt, costing a fresh keyframe and a visible hitch on the remote until that keyframe lands.
The second thing to internalise is that direction is a request, not a result. The negotiated direction is the intersection of what you offered with what the answerer accepted, so an offer of sendrecv answered with recvonly leaves you sendonly. transceiver.direction keeps reporting your intent; transceiver.currentDirection reports what the last completed cycle actually agreed, and stays null until one finishes. Debugging βthe track is attached but nothing arrivesβ almost always ends at code that read direction and believed it. Assigning a value the transceiver already holds is a no-op too, since the negotiation-needed flag is raised only when the pending direction actually changes.
Minimal Runnable Implementation
A promise-chained queue with a signalingState === 'stable' guard serialises every renegotiation, and direction changes replace track removal to keep streams attached.
const pc = new RTCPeerConnection(config);
let signalingQueue = Promise.resolve();
// Serialise: every negotiationneeded chains onto the previous renegotiation.
function scheduleRenegotiation() {
signalingQueue = signalingQueue.then(async () => {
// Guard: another negotiation may have just landed; only offer from stable.
if (pc.signalingState !== 'stable') return;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer); // stable -> have-local-offer
signaling.send({ type: 'offer', sdp: pc.localDescription.sdp });
console.log('RTC: [SDP] Local offer queued and transmitted');
} catch (err) {
console.error('RTC: [SDP] Renegotiation failed:', err.message);
}
});
}
pc.addEventListener('negotiationneeded', scheduleRenegotiation);
// Apply the answer to close the cycle back to stable.
async function handleRemoteAnswer(sdp) {
await pc.setRemoteDescription({ type: 'answer', sdp }); // have-local-offer -> stable
console.log('RTC: [SDP] Remote description applied successfully');
}
// Stop media without dropping the stream: change direction, don't removeTrack.
function pauseVideo(transceiver) {
transceiver.direction = 'recvonly'; // keeps SSRC mapping; decoder stays warm
// a subsequent 'sendrecv' resumes instantly without decoder reinit
}
Transmit pc.localDescription.sdp, never the raw offer.sdp, so the remote peer receives the browser-normalised description with stable mid mapping. Reordering transceivers between renegotiations shifts every mid and triggers the rejections covered in Debugging SDP m-line Mismatches, so keep the transceiver array append-only.
Coalescing a burst of triggers into one offer
Several synchronous addTrack() calls do not produce several events: the negotiation-needed flag is raised immediately but the event is dispatched from a queued task that clears it, so audio and video added in the same tick collapse into one negotiationneeded. The costly case is track changes separated by an await β a microphone added after getUserMedia() resolves, then a camera after a second permission prompt β because each lands in a different task and fires its own event. Those are the triggers the queue serialises, and folding them into one offer halves both the signalling round-trips and the remoteβs SDP parsing work:
let renegotiationPending = false; // collapses a burst of triggers into a single offer
function enqueueRenegotiation() {
// A pass is already scheduled and will carry every change made up to the moment it runs.
if (renegotiationPending) return;
renegotiationPending = true;
signalingQueue = signalingQueue.then(async () => {
renegotiationPending = false; // clear BEFORE offering: later changes must get their own pass
if (pc.signalingState !== 'stable') return; // re-check β a remote offer may have landed while we waited
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: pc.localDescription.sdp });
});
}
Clearing the flag after the offer is sent rather than before it is built is the classic lost-update bug here. A track added while the offer is in flight sees renegotiationPending === true, declines to schedule anything, and never gets a pass of its own, so its sender sits with no matching m-section until some unrelated change triggers the next renegotiation. Nothing throws and the local preview renders normally, so the failure β a remote participant who never receives the second stream β is routinely misfiled as a network problem.
Reproduction Steps & Debugging Log Patterns
- Establish a connection with an active audio and video track and confirm
signalingState === 'stable'. - Rapidly call
addTrack()andremoveTrack()in the same tick while ICE is still gathering to force overlappingnegotiationneededevents. - Without the queue, observe the failure signature:
InvalidStateError: Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is not stable. - Add the promise queue and repeat; the renegotiations now log in clean sequence rather than colliding.
- Watch the existing media element throughout β with direction changes (not
removeTrack()) the video never freezes, confirming the SSRC mapping survived.
A healthy renegotiation produces this transition log:
// Expected clean-path output
// RTC: [SDP] Local offer queued and transmitted
// RTC: [SDP] Remote description applied successfully
// (existing tracks stay live β no decoder reinit, no black frame)
Stalls show up as a missing Remote description applied line after the offer, meaning the answer never arrived or the queue is blocked on an unresolved promise. Confirm transport health with getStats() at 1-second intervals: the nominated candidate-pair should stay succeeded throughout, proving the renegotiation reused the existing path rather than restarting ICE.
A second telltale is the outbound-rtp report for the affected track. During a clean renegotiation its ssrc is unchanged and framesEncoded keeps incrementing across the offer/answer cycle; if you instead see the SSRC change or the counter reset to zero, the transceiver was rebuilt rather than updated β the signature of a removeTrack()/addTrack() round-trip or a reordered transceiver array. Pin that down before blaming the network, because a fresh SSRC forces the remote decoder to resynchronise and is exactly the freeze you are trying to avoid.
Browser-specific renegotiation behaviour
Chrome has been unified-plan-only since M93, with Plan B removed in M96, and it does not free an m-section when you call removeTrack(). The section survives in the SDP as a=recvonly with its mid reserved, and is only recycled β port rewritten to 0, then reused for a new transceiver β on a later negotiation, so an app that repeatedly adds and drops screen share grows its SDP by about one m-section per cycle until the recycle kicks in. Successive descriptions are readable pass by pass in the dump format covered in Reading chrome://webrtc-internals Dumps: diff pass N against pass N+1 and any recycling or reordering shows up immediately.
Firefox validates the answerβs m-section ordering more strictly than Chrome, rejecting a mismatched answer outright where Chrome will often remap and carry on, which makes it the useful canary in a cross-browser matrix: a sequence that survives Firefox is safe everywhere. Safari became reliable about firing negotiationneeded for a bare transceiver.direction assignment in the 16.4 WebKit generation; code written against Safari 15 usually called createOffer() explicitly after a direction change, and that legacy call becomes a duplicate-offer source once the event fires too. On iOS, backgrounding the app while an offer is in flight suspends the signalling socket, so the answer never lands and every later trigger fails the stable guard silently β a pattern examined further in Debugging WebRTC on Safari and iOS WKWebView.
Common Implementation Mistakes
- Calling
createOffer()without awaiting the previoussetLocalDescription/setRemoteDescription, allowing overlapping offers that corrupt the state machine. - Treating
signalingStateas synchronously'stable'after a track change β the transition is asynchronous, so guard inside the queued task, not before scheduling it. - Using
removeTrack()to pause media instead of setting the transceiver direction to'recvonly'or'inactive', which forces the remote decoder to drop and reinitialise the stream. - Reordering or splicing the transceiver array between renegotiations, shifting
midvalues and breaking m-line alignment. - Transmitting candidates and the renegotiation offer on overlapping timelines without buffering, causing candidate-parse failures on the remote side.
- Triggering an ICE restart for an ordinary track change β it is unnecessary and discards the working transport.
- Clearing a coalescing flag after the offer is transmitted rather than before it is built, so a change made mid-flight is swallowed and never gets a renegotiation pass.
- Leaving a stalled offer in
have-local-offerwith no timeout, which turns the stable guard into a permanent mute button on all further renegotiation. - Reading
transceiver.directionto decide whether media is flowing instead ofcurrentDirection, which is the only property reflecting what the peers actually agreed.
FAQ
Can I renegotiate while ICE candidates are still gathering?
Yes. ICE gathering and the offer/answer exchange run independently; renegotiation neither halts nor restarts gathering. Gate the queue on signalingState returning to 'stable', not on iceGatheringState reaching 'complete'.
Why do existing video tracks freeze during mid-stream renegotiation?
The usual cause is the remote peer rebuilding transceiver mappings instead of updating them, which happens when the new offer changes m= line order or mid values. Keep the transceiver array append-only and use direction changes rather than track removal so the existing SSRC mapping survives.
Is an ICE restart required to add or remove a track?
No. iceRestart: true is strictly for recovering a failed network path, and even then it can be done without a media gap β see Triggering an ICE Restart Without Dropping Media. Standard track renegotiation applies incremental SDP updates over the existing ICE transport and DTLS session with no media interruption.
The answer never arrived and now nothing renegotiates β how do I recover?
The connection is parked in have-local-offer, so every queued task hits the stable guard and returns silently; nothing throws, and the symptom is simply that no further track change ever reaches the remote. Wrap the outgoing offer in a 3β5 s timeout and, on expiry, call pc.setLocalDescription({ type: 'rollback' }) to drop the pending local description and return the connection to stable, then re-queue the pass. Roll back rather than tear down: rollback discards only the half-applied negotiation, leaving the ICE transport, the DTLS session and every live stream intact.
Do I need to renegotiate to change the send bitrate?
No. RTCRtpSender.setParameters() mutates encoding parameters β maxBitrate, scaleResolutionDownBy, active β entirely out of band, and the change is visible in outbound-rtp within one or two 1-second getStats intervals with no SDP exchange at all. A new offer is only required when the set of encodings changes, such as introducing a third simulcast layer that was absent from the original description, because the layer list is negotiated in the m-section rather than carried in sender parameters.
Related: return to the SDP Offer/Answer Lifecycle guide, and see Debugging SDP m-line Mismatches and Munging SDP to Prefer Opus DTX.