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. 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. When only one side ever initiates renegotiation β€” the typical client-to-SFU topology β€” the queue alone is sufficient and glare cannot occur.

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.

Reproduction Steps & Debugging Log Patterns

  1. Establish a connection with an active audio and video track and confirm signalingState === 'stable'.
  2. Rapidly call addTrack() and removeTrack() in the same tick while ICE is still gathering to force overlapping negotiationneeded events.
  3. Without the queue, observe the failure signature: InvalidStateError: Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is not stable.
  4. Add the promise queue and repeat; the renegotiations now log in clean sequence rather than colliding.
  5. 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.

Common Implementation Mistakes

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. Standard track renegotiation applies incremental SDP updates over the existing ICE transport and DTLS session with no media interruption.

Related: return to the SDP Offer/Answer Lifecycle guide, and see Debugging SDP m-line Mismatches and Munging SDP to Prefer Opus DTX.