Recovering from Glare in WebRTC Offer Collisions
When two peers call createOffer() at the same instant, both land in have-local-offer and neither has a legal path back to stable — a collision the telephony world calls glare. This guide is part of the Signaling State Machine Patterns section, and it covers the exact decision that resolves it: implementing the perfect-negotiation pattern so one peer rolls back and accepts the other’s offer instead of throwing InvalidStateError.
Context & Trade-offs
Glare is not rare. Any time both sides can renegotiate — replacing a track, toggling a screen share, restarting ICE — a negotiationneeded event can fire on both peers within the same round trip. Without arbitration, each applies its own local offer, receives the other’s offer while in have-local-offer, and rejects it because that state does not accept a remote offer. The session wedges until something tears it down.
The robust fix is the W3C-recommended perfect-negotiation pattern: assign each peer a fixed role — one polite, one impolite — and let role decide who yields. The polite peer rolls back its own pending offer and accepts the incoming one; the impolite peer ignores the colliding offer and keeps its own. Roles must be assigned out of band (e.g. first peer in the room is impolite, or compare peer IDs) and must be stable for the session’s life.
The alternative — a hand-rolled “lower ID always wins, higher ID retries after a delay” scheme — works but reintroduces timing races and retry storms the perfect-negotiation rollback avoids entirely. The trade-off for perfect negotiation is that it depends on setLocalDescription() with no argument (implicit description) and rollback support; both are available in current Chrome, Firefox, and Safari, but older Safari needs explicit handling. Glare resolution typically adds a single extra round trip — well under the 3–5 s fallback timeouts you already budget for — so the cost is negligible against the reliability gain.
Minimal Runnable Implementation
The pattern hinges on two flags — makingOffer and ignoreOffer — plus the fixed polite role. The perfect-negotiation logic lives entirely in the negotiationneeded handler and the inbound-description handler; no custom state machine timers are required.
// polite is assigned out of band and is stable for the session
let makingOffer = false;
let ignoreOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
// Implicit description: the browser creates the right offer/answer for current state
await pc.setLocalDescription();
signaler.send({ description: pc.localDescription });
} catch (err) {
console.error('[glare] negotiation failed:', err);
} finally {
makingOffer = false; // clear before any inbound collision check
}
};
signaler.onmessage = async ({ description, candidate }) => {
if (description) {
// Collision: a remote offer arrives while we are mid-offer or not stable
const offerCollision =
description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable');
ignoreOffer = !polite && offerCollision; // impolite peer ignores the offer
if (ignoreOffer) return; // keep our own offer, drop theirs
// Polite peer: rollback (if colliding) is implicit in setRemoteDescription
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription(); // answer back from a clean state
signaler.send({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
// Swallow only the candidates we deliberately ignored after dropping an offer
if (!ignoreOffer) throw err;
}
}
};
Two details make this correct. First, setRemoteDescription(offer) while in have-local-offer performs an implicit rollback for the polite peer — modern browsers reset to stable and apply the remote offer in one call, so you do not manually call { type: 'rollback' }. Second, the impolite peer must also discard the ICE candidates that belonged to the offer it ignored, which is why addIceCandidate failures are swallowed only while ignoreOffer is set. The SDP transitions underneath this — which states legally accept an offer versus an answer — are defined by the SDP Offer/Answer Lifecycle, and getting glare right means trusting those native transitions rather than fighting them.
Reproduction Steps & Debugging Log Patterns
- Connect two peers and reach
stable/connected. - On both peers simultaneously, call
pc.getSenders()[0].replaceTrack(newTrack)followed by a manualdispatchEventor a real track swap to firenegotiationneededon each side at once. - Observe both peers entering
have-local-offerbefore either receives the other’s offer. - Confirm the impolite peer logs an ignored offer and the polite peer logs a rollback-then-answer.
- Verify the connection returns to
stableon both sides with noInvalidStateError.
Expected and diagnostic log lines:
# Healthy glare resolution
[peer-impolite] negotiationneeded -> setLocalDescription(offer)
[peer-polite] negotiationneeded -> setLocalDescription(offer)
[peer-impolite] inbound offer while making offer -> ignoreOffer=true (kept ours)
[peer-polite] inbound offer collision -> implicit rollback -> setRemoteDescription ok
[peer-polite] setLocalDescription(answer) -> sent
[both] signalingState: stable
# Symptom of missing perfect-negotiation logic
DOMException: Failed to set remote offer sdp: Called in wrong state: have-local-offer
-> peer accepted a colliding offer without rollback; add the offerCollision guard
# Symptom of swapped/duplicate roles
[both] ignoreOffer=true -> both peers impolite; negotiation deadlocks, fix role assignment
Common Implementation Mistakes
- Both peers polite or both impolite. If roles are not mutually exclusive, either both yield (deadlock) or neither yields (
InvalidStateError). Assign exactly one polite peer per pair, out of band. - Checking only
makingOffer, notsignalingState. A collision can occur when an offer is already applied but not yet answered; gate onmakingOffer || pc.signalingState !== 'stable'. - Manually calling
{ type: 'rollback' }in the polite path. With implicit descriptions,setRemoteDescriptionrolls back for you; an explicit rollback on top of that throws in Firefox fromstable. - Not discarding the ignored offer’s candidates. The impolite peer keeps receiving candidates for the offer it dropped; let those
addIceCandidatecalls fail quietly whileignoreOfferis set. - Re-assigning roles on reconnect. Roles must stay stable for the session; recomputing them after an ICE restart can flip both peers and reintroduce glare.
FAQ
How do I decide which peer is polite?
Pick any stable, out-of-band rule both peers agree on before negotiation: the room creator is impolite and joiners are polite, or compare peer IDs and make the lexicographically lower one polite. The only requirement is that exactly one peer per pair is polite and the assignment never changes mid-session.
Does glare resolution drop media or require a full renegotiation?
No. The implicit rollback on the polite peer resets only the signaling state; the existing RTP/RTCP flows and tracks are untouched, and the single extra offer/answer round trip completes well within normal renegotiation budgets. Media continues uninterrupted.
Related: build the surrounding machine in the Signaling State Machine Patterns guide, carry the same buffering into typed transports with Custom Signaling Protocols with gRPC-Web, and ground the state rules in the SDP Offer/Answer Lifecycle.