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.

signalingState transitions under a glare collision From stable, a local offer moves the peer to have-local-offer. A colliding remote offer either loops back on itself when the peer is impolite and ignores it, or rolls back implicitly into have-remote-offer when the peer is polite, after which a local answer returns the peer to stable. signalingState during a collision stable have-local-offer have-remote-offer negotiationneeded → local offer no collision: setRemoteDescription(answer) impolite: ignoreOffer = true (keep our offer) polite: setRemoteDescription(offer) = implicit rollback, no manual { type: 'rollback' } call setLocalDescription (answer) Glare = both peers sitting in have-local-offer at once; only the polite peer leaves it by rollback.
The polite peer's rollback edge is the only legal exit from a doubled have-local-offer.

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 sendrecvrecvonly 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.

Message timeline of a resolved offer collision Both peers call setLocalDescription at t0 and send offers that cross in flight. At t2 the polite peer detects the collision and rolls back while the impolite peer sets ignoreOffer. At t3 the polite peer sends an answer and both peers return to stable one round trip later. polite peer impolite peer t0 t1 t2 t3 t4 setLocalDescription() setLocalDescription() offer P → ← offer I collision → implicit rollback collision → ignoreOffer = true answer to offer I stable stable One extra round trip end to end — inside the 3–5 s fallback timeout, with RTP flows untouched.
Crossed offers resolve in a single extra round trip: the polite peer rolls back, the impolite peer holds.

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

  1. Connect two peers and reach stable/connected.
  2. On both peers simultaneously, call pc.getSenders()[0].replaceTrack(newTrack) followed by a manual dispatchEvent or a real track swap to fire negotiationneeded on each side at once.
  3. Observe both peers entering have-local-offer before either receives the other’s offer.
  4. Confirm the impolite peer logs an ignored offer and the polite peer logs a rollback-then-answer.
  5. Verify the connection returns to stable on both sides with no InvalidStateError.

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.

Branch map of the inbound-description handler An inbound description is first tested for being an offer; non-offers are applied as answers. Offers are tested for collision against makingOffer and signalingState; non-colliding offers take the normal answer path. Colliding offers branch on the polite role: polite peers roll back and answer, impolite peers set ignoreOffer and drop the offer and its candidates. inbound description type === 'offer' ? no apply as answer yes makingOffer || state !== 'stable' ? no plain renegotiation yes: glare polite ? no ignoreOffer = true, return yes rollback, then answer Only the bottom-left leaf may swallow addIceCandidate errors — everywhere else they are real bugs.
Every log line above maps to one leaf of this branch map for the inbound-description handler.

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-offerhave-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

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.