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:
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 passingnull),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.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:
Triggering the
negotiationneededEvent CallingpeerConnection.addTrack()orpeerConnection.removeTrack()flags the connection as needing synchronization and fires thenegotiationneededevent on theRTCPeerConnectioninstance.Creating and Setting the Local Offer The calling peer creates a new offer reflecting the updated media tracks using
createOffer()and applies it locally withsetLocalDescription().Signaling the Offer The local description is sent to the remote peer via your signaling channel (e.g., WebSockets).
Remote Description & Answer Generation The remote peer receives the offer, sets it as its remote description via
setRemoteDescription(), generates an answer usingcreateAnswer(), sets it locally (setLocalDescription()), and sends that answer back through the signaling channel.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.