The WebRTC perfect negotiation pattern
Introduces WebRTC perfect negotiation, describing how it works and why it's the recommended way to negotiate a WebRTC connection between peers
Perfect negotiation concept
The best thing about perfect negotiation is that the same code is used for both the caller and the callee, so there's no repetition or otherwise added levels of negotiation code to write.
Perfect negotiation works by assigning each of the two peers a role to play in the negotiation process that's entirely separate from the WebRTC connection state:
-
A polite peer, which uses ICE rollback to prevent collisions with incoming offers. A polite peer, essentially, is one which may send out offers, but then responds if an offer arrives from the other peer with "Okay, never mind, drop my offer and I'll consider yours instead."
-
An impolite peer, which always ignores incoming offers that collide with its own offers. It never apologizes or gives up anything to the polite peer. Any time a collision occurs, the impolite peer wins.
This way, both peers know exactly what should happen if there are collisions between offers that have been sent. Responses to error conditions become far more predictable.
The roles of caller and callee can switch during perfect negotiation. If the polite peer is the caller and it sends an offer but there's a collision with the impolite peer, the polite peer drops its offer and instead replies to the offer it has received from the impolite peer. By doing so, the polite peer has switched from being the caller to the callee!
Implement perfect negotiation
Assumes that there's a SignalingChannel
class defined that is used to communicate with the signaling server.
Create the signaling and peer connections
First, the signaling channel needs to be opened and the RTCPeerConnection
needs to be created.
const config = {
iceServers: [{ urls: "stun:stun.mystunserver.tld" }],
};
const signaler = new SignalingChannel();
const pc = new RTCPeerConnection(config);
Connecting to a remote peer
const constraints = { audio: true, video: true };
const selfVideo = document.querySelector("video.selfview");
const remoteVideo = document.querySelector("video.remoteview");
async function start() {
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
for (const track of stream.getTracks()) {
pc.addTrack(track, stream);
}
selfVideo.srcObject = stream;
} catch (err) {
console.error(err);
}
}
The start()
function shown above can be called by either of the two end-points that want to talk to one another. It doesn't matter who does it first; the negotiation will just work.
The user's camera and microphone are obtained by calling getUserMedia()
.
The resulting media tracks are then added to the RTCPeerConnection
by passing them into addTrack()
.
Then, finally, the media source for the self-view <video>
element indicated by the selfVideo
constant is set to the camera and microphone stream, allowing the local user to see what the other peer sees.
Handling incoming tracks
pc.ontrack = ({ track, streams }) => {
track.onunmute = () => {
if (remoteVideo.srcObject) {
return;
}
remoteVideo.srcObject = streams[0];
};
};
When the track
event occurs, this handler executes. Extract track
and streams
properties from the RTCTrackEvent
.
track
is either the video track or the audio track being received.
streams
is an array of MediaStream
objects, each representing a stream containing this track (a track may in rare cases belong to multiple streams at once).
In our case, this will always contain one stream, at index 0, because we passed one stream into addTrack()
earlier.
We add an unmute event handler to the track, because the track will become unmuted once it starts receiving packets.
If we already have video coming in from the remote peer (which we can see if the remote view's <video>
element's srcObject
property already has a value), we do nothing. Otherwise, we set srcObject
to the stream at index 0 in the streams
array.
The perfect negotiation logic
Now we get into the true perfect negotiation logic, which functions entirely independently from the rest of the application.
Handling the negotiationneeded event
First, we implement the RTCPeerConnection
event handler onnegotiationneeded
to get a local description and send it using the signaling channel to the remote peer.
let makingOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
Note that setLocalDescription()
without arguments automatically creates and sets the appropriate description based on the current signalingState
. The set description is either an answer to the most recent offer from the remote peer or a freshly-created offer if there's no negotiation underway. Here, it will always be an offer
, because the negotiationneeded event is only fired in stable
state.
We set a Boolean variable, makingOffer
to true
to mark that we're preparing an offer. To avoid races, we'll use this value later instead of the signaling state to determine whether or not an offer is being processed because the value of signalingState
changes asynchronously, introducing a glare opportunity.
Once the offer has been created, set and sent (or an error occurs), makingOffer gets set back to false.
Handling incoming ICE candidates
Next, we need to handle the RTCPeerConnection
event icecandidate
, which is how the local ICE layer passes candidates to us for delivery to the remote peer over the signaling channel.
pc.onicecandidate = ({ candidate }) => signaler.send({ candidate });
This takes the candidate
member of this ICE event and passes it through to the signaling channel's send()
method to be sent over the signaling server to the remote peer.
Handling incoming messages on the signaling channel
That's implemented here as an onmessage
event handler on the signaling channel object. This method is invoked each time a message arrives from the signaling server.
let ignoreOffer = false;
signaler.onmessage = async ({ data: { description, candidate } }) => {
try {
if (description) {
const offerCollision =
description.type === "offer" &&
(makingOffer || pc.signalingState !== "stable");
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) {
return;
}
await pc.setRemoteDescription(description);
if (description.type === "offer") {
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) {
throw err;
}
}
}
} catch (err) {
console.error(err);
}
};
Upon receiving an incoming message from the SignalingChannel
through its onmessage
event handler, the received JSON object is destructured to obtain the description
or candidate
found within. If the incoming message has a description
, it's either an offer or an answer sent by the other peer.
If, on the other hand, the message has a candidate
, it's an ICE candidate received from the remote peer as part of trickle ICE. The candidate is destined to be delivered to the local ICE layer by passing it into addIceCandidate()
.
ON RECEIVING A DESCRIPTION
If we received a description
, we prepare to respond to the incoming offer or answer. First, we check to make sure we're in a state in which we can accept an offer. If the connection's signaling state isn't stable
or if our end of the connection has started the process of making its own offer, then we need to look out for offer collision.
If we're the impolite peer, and we're receiving a colliding offer, we return without setting the description, and instead set ignoreOffer
to true
to ensure we also ignore all candidates the other side may be sending us on the signaling channel belonging to this offer. Doing so avoids error noise since we never informed our side about this offer.
If we're the polite peer, and we're receiving a colliding offer, we don't need to do anything special, because our existing offer will automatically be rolled back in the next step.
Having ensured that we want to accept the offer, we set the remote description to the incoming offer by calling setRemoteDescription()
. This lets WebRTC know what the proposed configuration of the other peer is. If we're the polite peer, we will drop our offer and accept the new one.
If the newly-set remote description is an offer, we ask WebRTC to select an appropriate local configuration by calling the RTCPeerConnection
method setLocalDescription()
without parameters. This causes setLocalDescription()
to automatically generate an appropriate answer in response to the received offer. Then we send the answer through the signaling channel back to the first peer.
ON RECEIVING AN ICE CANDIDATE
On the other hand, if the received message contains an ICE candidate, we deliver it to the local ICE layer by calling the RTCPeerConnection
method addIceCandidate()
. If an error occurs and we've ignored the most recent offer, we also ignore any error that may occur when trying to add the candidate.
Making perfect negotiation
For more information, please visit Establishing a connection: The WebRTC perfect negotiation pattern - MDN to deep dive and see old API implement vs. updated API implement for more understanding.
In here, I just note updated API implementation
Glare-free setLocalDescription()
In the past, the negotiationneeded
event was easily handled in a way that was susceptible to glare---that is, it was prone to collisions, where both peers could wind up attempting to make an offer at the same time, leading to one or the other peers getting an error and aborting the connection attempt.
let makingOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
We set makingOffer
immediately before calling setLocalDescription()
in order to lock against interfering with sending this offer, and we don't clear it back to false
until the offer has been sent to the signaling server (or an error has occurred, preventing the offer from being made).
This way, we avoid the risk of offers colliding.
Automatic rollback in setRemoteDescription()
let ignoreOffer = false;
signaler.onmessage = async ({ data: { description, candidate } }) => {
try {
if (description) {
const offerCollision =
description.type === "offer" &&
(makingOffer || pc.signalingState !== "stable");
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) {
return;
}
await pc.setRemoteDescription(description);
if (description.type === "offer") {
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) {
throw err;
}
}
}
} catch (err) {
console.error(err);
}
};
ON RECEIVING A DESCRIPTION
In the revised code, if the received message is an SDP description
, we check to see if it arrived while we're attempting to transmit an offer. If the received message is an offer
and the local peer is the impolite peer, and a collision is occurring, we ignore the offer because we want to continue to try to use the offer that's already in the process of being sent. That's the impolite peer in action.
In any other case, we'll try instead to handle the incoming message. This begins by setting the remote description to the received description
by passing it into setRemoteDescription()
. This works regardless of whether we're handling an offer or an answer since rollback will be performed automatically as needed.
At that point, if the received message is an offer
, we use setLocalDescription()
to create and set an appropriate local description, then we send it to the remote peer over the signaling server.
ON RECEIVING AN ICE CANDIDATE
On the other hand, if the received message is an ICE candidate---indicated by the JSON object containing a candidate
member---we deliver it to the local ICE layer by calling the RTCPeerConnection
method addIceCandidate()
. Errors are, as before, ignored if we have just discarded an offer.
Explicit restartIce() method added
let makingOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
} catch (err) {
console.error(err);
} finally {
makingOffer = false;
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "failed") {
pc.restartIce();
}
};
restartIce()
tells the ICE layer to automatically add the iceRestart
flag to the next ICE message sent.