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 — a track swap of the kind covered in Replacing Video Tracks Without Renegotiation, a screen-share toggle, or Triggering an ICE Restart Without Dropping Media — 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.
Why rollback is the only cheap exit
The reason the collision is recoverable at all is that JSEP keeps two description slots per direction, not one. In have-local-offer the peer still holds a valid currentLocalDescription/currentRemoteDescription pair from the last completed negotiation, and the un-answered offer sits separately in pendingLocalDescription. Rollback discards the pending slot and re-derives stable from the current pair. Nothing durable is torn down: the ICE agent keeps its username fragment and its selected candidate pair, DTLS is not renegotiated, and the SRTP keys stay in place. That is precisely why glare recovery costs one signaling round trip rather than the multi-second reconnect an ICE restart implies — the transport never notices.
What rollback does unwind is the transceiver state the pending offer proposed. A transceiver the polite peer created just before the collision reverts to mid === null and to whichever direction the last answer negotiated, and any direction change (say sendrecv → recvonly from a mute) is reverted with it. Those changes are not lost, though: the browser re-evaluates the negotiation-needed flag once the peer is back in stable, so negotiationneeded fires again and the same intent is re-proposed on the next offer — this time from the clean state established by the impolite peer’s offer. The practical consequence is that a polite peer adding a track during glare sees two negotiationneeded events for one user action, and any code that counts negotiations or gates on “we already offered this track” will misfire.
Browser support splits along predictable lines. Chrome has shipped implicit setLocalDescription() and implicit rollback since M80, and Firefox since 75; both behave identically for the code above. Safari added no-argument setLocalDescription() in Safari 15, so on Safari 13/14 and the iOS WKWebView of that generation you must call createOffer()/createAnswer() explicitly and cannot rely on setRemoteDescription() performing the rollback for you. On those builds the safest fallback is a designated-offerer scheme where only one side may ever call createOffer(), which removes glare by construction at the cost of an extra signaling hop whenever the non-offerer needs a renegotiation.
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. When no collision occurs the handler degrades to the ordinary sequencing described in SDP Renegotiation Without Dropping Streams, so you are not paying for the guard on the common path.
Laid out on a timeline, the two offers cross in flight and each peer reaches the collision check at a different point in its own local state — which is why the guard has to consult signalingState and not just a message counter.
Serialising inbound descriptions
The handler above is correct only if one description is processed at a time. Every branch contains an await, and setRemoteDescription() on a large SDP takes single-digit milliseconds — long enough that a second message delivered over a WebSocket carrying sub-10 ms latency can enter the handler while the first is still suspended. The signalingState read in the collision guard then reflects a state the previous invocation is about to change, and the peer either double-answers or throws InvalidStateError from a state it believed was stable. Chain the handler through a single promise so descriptions apply strictly in arrival order.
// One serialisation point for every inbound signaling message on this peer
let chain = Promise.resolve();
const enqueue = (task) => (chain = chain.then(task).catch((err) => {
console.error('[glare] handler rejected:', err); // never break the chain
}));
signaler.onmessage = (msg) => enqueue(() => handleSignal(msg));
// Guard against a stale role: the server stamps polite once, at join time
function applyJoinAck(ack) {
if (polite !== undefined && ack.polite !== polite) {
// Roles flipped mid-session — treat as fatal, not as something to reconcile
throw new Error(`[glare] role changed ${polite} -> ${ack.polite}`);
}
polite = ack.polite; // stable for the lifetime of this peer connection
}
Ordering is a property of the transport, not of this code. A single WebSocket connection preserves per-peer order for free, but the moment signaling fans out through a broker — the topology described in Scaling WebSocket Signaling with Redis Pub/Sub — an offer and the answer that follows it can traverse different shards and arrive out of order. Stamp each description with a monotonically increasing per-peer sequence number and drop anything older than the last applied value; the collision guard cannot distinguish a reordered stale offer from a genuine new one.
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
Each of those log lines corresponds to exactly one branch of the inbound-description handler, so tracing a bad session is a matter of finding which branch the peer actually took.
Confirming the rollback in the browser’s own trace
Application logs tell you which branch your code took; they do not prove the browser agreed. Open chrome://webrtc-internals on the polite peer and read the event list for the connection, which is the same trace dissected in Reading chrome://webrtc-internals Dumps. A resolved collision leaves a recognisable signature: setLocalDescription (offer), then setRemoteDescription (offer) with no intervening setRemoteDescription (answer), then setLocalDescription (answer). There is no explicit “rollback” event — the implicit rollback is folded into that second setRemoteDescription, which is why engineers searching the trace for the word conclude, wrongly, that rollback never happened. Cross-check against signalingstatechange: you should see have-local-offer → have-remote-offer without passing through a logged stable.
Two collision signatures are worth learning to recognise while you are in there. If the polite peer shows setRemoteDescription (offer) failing with Called in wrong state, its build lacks implicit rollback — check the Safari/WKWebView versions above before touching your own code. If instead the trace shows a clean rollback followed by an addIceCandidate error burst on the impolite side, the guard worked but candidates from the dropped offer are still being applied; those errors are expected and must be swallowed only while ignoreOffer is set. Firefox’s about:webrtc records the same sequence under its signaling log with SetRemoteDescription (offer) entries, though it flattens the state names, so correlate by timestamp rather than by label.
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. Carry the assigned role in the same envelope you rehydrate in Reconnecting Signaling Sockets Without Losing Session State rather than deriving it again on each socket.
- Deriving the role from join order on a replicated room store. Two peers that join within the replication lag of a broker can both read an empty member list and both conclude they are first, producing two impolite peers on a link that looked fine in single-node testing. Have one authority stamp the role and echo it in the join acknowledgement.
- Retrying the rolled-back offer by hand. The polite peer’s pending change is re-proposed automatically when
negotiationneededrefires after the peer returns tostable; issuing your own follow-up offer produces a second collision immediately after the first, and under a symmetric retry delay the pair can oscillate for several seconds. Cap deliberate renegotiation attempts at the same 3 retries you allow for ICE restarts. - Counting
negotiationneededevents as user actions. A single track add can raise the event twice on the polite peer — once for the offer that gets rolled back, once after recovery. Debouncing logic keyed on “we already negotiated this” silently drops the second, and the track never reaches the remote peer.
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.
Does this pattern apply to a client-to-server connection with an SFU?
Yes, and it simplifies: make the server permanently impolite and every client polite. The SFU is the authority on the shape of the session — which layers it forwards, how many transceivers exist — so a client offer that collides with a server offer should always lose. Many SFUs go further and refuse client-initiated offers entirely, exposing an RPC the client calls to request a change; that is a designated-offerer scheme in disguise, and it removes the collision case rather than resolving it.
Why does my polite peer occasionally answer with fewer m-lines than it offered?
Because rollback reverted the transceivers the discarded offer had created, and the answer is generated against the impolite peer’s offer, not against your own intent. The missing media appears one round trip later when the refired negotiationneeded proposes it again. If it never appears, the ordering problem is elsewhere — chase it through Debugging SDP m-line Mismatches rather than through the glare guard.
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.