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

  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

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.

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.