WebRTC Dynamic Track Renegotiation in JavaScript

Dynamic track renegotiation in WebRTC allows active peer connections to modify their media configurations—such as adding, removing, or switching audio and video tracks—without terminating the session. This article explains how JavaScript handles these dynamic adjustments, covering the distinction between seamless track replacement and full renegotiation, the role of the negotiationneeded event, and the implementation of the Perfect Negotiation pattern to prevent signaling collisions.


Modifying Tracks: Seamless Replacement vs. Full Renegotiation

When working with WebRTC media tracks dynamically, JavaScript provides two primary methods:

  1. RTCRtpSender.replaceTrack() (No Renegotiation Required) If you only need to swap one media source for another of the same type (for example, switching from a user-facing camera to an environment-facing camera, or muting video by passing null), replaceTrack() alters the transmitted media without modifying the SDP (Session Description Protocol). Because the underlying media stream format and direction do not change, no renegotiation is needed.

  2. RTCPeerConnection.addTrack() / removeTrack() (Renegotiation Required) When you change the number of tracks, introduce a new media type (e.g., adding screen sharing to an audio-only call), or change media direction, the structural definition of the session changes. This requires generating a new SDP Offer/Answer exchange to align capabilities between peers.


The Dynamic Renegotiation Workflow

When a structural change occurs, WebRTC follows a structured renegotiation pipeline:

  1. Triggering the negotiationneeded Event Calling peerConnection.addTrack() or peerConnection.removeTrack() flags the connection as needing synchronization and fires the negotiationneeded event on the RTCPeerConnection instance.

  2. Creating and Setting the Local Offer The calling peer creates a new offer reflecting the updated media tracks using createOffer() and applies it locally with setLocalDescription().

  3. Signaling the Offer The local description is sent to the remote peer via your signaling channel (e.g., WebSockets).

  4. Remote Description & Answer Generation The remote peer receives the offer, sets it as its remote description via setRemoteDescription(), generates an answer using createAnswer(), sets it locally (setLocalDescription()), and sends that answer back through the signaling channel.

  5. Completing the Exchange The originating peer applies the received answer using setRemoteDescription(). The new tracks begin streaming immediately.


The Perfect Negotiation Pattern

In dynamic applications, both peers might try to add tracks simultaneously, causing a glare condition (offer collision). The standard approach to resolve this in JavaScript is the Perfect Negotiation pattern, which designates one peer as “polite” and the other as “impolite.”

let makingOffer = false;
const polite = true; // One peer is polite, the other is set to false

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaling.send({ description: pc.localDescription });
  } catch (err) {
    console.error("Negotiation error:", err);
  } finally {
    makingOffer = false;
  }
};

signaling.onmessage = async ({ data: { description, candidate } }) => {
  try {
    if (description) {
      // Check for offer collision
      const offerCollision = description.type === "offer" &&
        (makingOffer || pc.signalingState !== "stable");

      // Ignore offer if impolite and collision occurs
      if (offerCollision && !polite) {
        return;
      }

      // Polite peer rolls back local description to accept the incoming offer
      await pc.setRemoteDescription(description);

      if (description.type === "offer") {
        await pc.setLocalDescription();
        signaling.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      await pc.addIceCandidate(candidate);
    }
  } catch (err) {
    console.error("Signaling handling error:", err);
  }
};

Dynamically Adding and Removing Media

To dynamically inject a new stream (like a screen capture), capture the track and append it to the connection:

// Adding a screen share track
async function startScreenShare(pc) {
  const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
  const screenTrack = screenStream.getVideoTracks()[0];

  // Adding the track automatically fires 'negotiationneeded'
  const sender = pc.addTrack(screenTrack, screenStream);

  // Stop sharing handler
  screenTrack.onended = () => {
    pc.removeTrack(sender); // Fires 'negotiationneeded' to clean up SDP
  };
}

By relying on RTCRtpSender.replaceTrack() for simple swaps and combining negotiationneeded with the Perfect Negotiation pattern for structural changes, JavaScript applications can dynamically alter WebRTC audio and video feeds without stream interruptions.