§ 4.5  Module 4 — The Browser Gets a Dial Tone

Perfect Negotiation: Taming Glare

Be conservative in what you do, be liberal in what you accept from others.— Jon Postel, RFC 761, 1980

By the end of this lesson

  • Explain glare and why naive renegotiation code corrupts signaling state
  • Implement the full perfect-negotiation pattern: polite flag, makingOffer, ignoreOffer, implicit rollback
  • Use parameterless setLocalDescription() and explain why it matters here
  • Prove convergence under randomized adversarial timing

Historical note

Glare is the oldest race condition in telephony. On the Bell System's both-way trunks — circuits that either end could seize — two central offices could grab the same trunk within the round-trip signaling window, each believing it owned the call. Traffic engineers named the collision glare, and their mitigation, standard practice from the first No. 5 crossbar installation (Media, Pennsylvania, 1948) onward, was asymmetry: the two offices hunted the shared trunk group from opposite ends to make collisions rare, and one office was designated in advance to yield when a double seizure happened anyway. ISDN inherited the race and the shape of the answer — under ITU-T Q.931 (1988), when user and network selected the same B-channel simultaneously, the network's choice simply won. SIP (RFC 3261, June 2002 — Rosenberg, Schulzrinne, Camarillo, and colleagues) met glare in simultaneous re-INVITEs and answered with the 491 Request Pending response plus deliberately unequal retry timers keyed to Call-ID ownership. WebRTC then made the race universal: RTCPeerConnection fires negotiationneeded whenever the session must change, so any bidirectional feature — mute, screenshare, a second camera — can launch offers from both ends at once. From roughly 2013 to 2018, applications shipped folklore workarounds (boolean latches, offer queues, teardown-and-redial) that corrupted signaling state under load. In 2019, Jan-Ivar Bruaroey of Mozilla published the perfect-negotiation pattern and drove the API changes it required — parameterless setLocalDescription(), implicit rollback, restartIce() — into the W3C WebRTC 1.0 specification, which carries the pattern as its canonical example and reached Recommendation on January 26, 2021. A century of glare, one recurring answer: make the peers unequal.

Surviving hardwarephotographed 2017
No. 5 Crossbar marker

A completing marker from a No. 5 Crossbar office, preserved at the Connections Museum in Seattle: row upon row of relays whose whole job was to pick an idle trunk and build the path through the switch frames. On both-way trunk groups, two offices could pick the same circuit inside the signaling round trip — and the remedy was never cleverness. Hunt the group from opposite ends so collisions stay rare, and decide in advance which office yields when one happens anyway. Perfect negotiation’s polite and impolite peers are that rule restated for browsers, seventy years later.

NeveretherCC BY-SA 4.0Wikimedia Commons

4.5.1The anatomy of glare

Recall the machinery from the previous two lessons. JSEP models every RTCPeerConnection as a small state machine driven by descriptions: applying a local offer moves signalingState from stable to have-local-offer; applying the remote answer returns it to stable; the mirror-image path runs through have-remote-offer. The offer/answer model underneath (RFC 3264, Rosenberg and Schulzrinne, June 2002) is explicit about concurrency: an agent must not generate a new offer while an offer it sent is unanswered, and must not generate one while holding an offer it has not yet answered. One offer in flight, ever.

That rule is easy to state and impossible for two independent machines to obey over an asynchronous channel without coordination. Suppose peers A and B both add a track within the same few milliseconds. Both fire negotiationneeded, both create offers, both enter have-local-offer, and the two offers cross on the wire. That is glare. Each peer now receives an offer in a state where the only legal input is an answer.

Why can't both offers win? Because an offer is not a request for resources; it is a proposed next version of the shared session — the complete SDP, media section by media section. Two crossed offers are two conflicting proposals forked from the same base version, and offer/answer defines no merge operation. If A applied B's offer while B applied A's, each side would answer a different question: A's session would be (B's offer, A's answer) while B's would be (A's offer, B's answer) — two different negotiated sessions on the two ends of one connection, disagreeing about m-line order, directions, even how many media sections exist. Someone must abandon their proposal. The whole problem of glare reduces to deciding who, deterministically, with no extra round trips.

SIP resolved the same deadlock with rejection and randomized, asymmetric retry timers — workable, but it adds seconds of latency and still leaves a small re-collision probability. WebRTC can do better because the application controls the code at both endpoints: assign the asymmetry in advance. One peer is designated polite, the other impolite, and glare resolves in zero additional round trips.

4.5.2Polite society: roles and the two guard flags

The role assignment is arbitrary but must be agreed and asymmetric: exactly one polite peer per pair. Any deterministic rule works — the peer that joined the signaling room second is polite, or compare session identifiers lexicographically. The behavioral contract is short. The impolite peer, on detecting a collision, ignores the incoming offer entirely and keeps waiting for the answer to its own. The polite peer, on detecting a collision, abandons its own offer — rolls it back — applies the incoming one, and answers it. The impolite peer's offer always wins; the polite peer's abandoned changes are renegotiated immediately afterward, so nothing is lost.

Detection needs two locally scoped flags. The first, makingOffer, is set before calling setLocalDescription() in the negotiationneeded handler and cleared in a finally once it settles. It exists because of an asynchronous blind spot: from the moment the handler starts until setLocalDescription() resolves, the connection is busy producing an offer, yet signalingState still reads stable. An offer arriving inside that window is a collision that the state machine alone cannot see. The collision predicate therefore reads:

const offerCollision = description.type === 'offer' &&
    (makingOffer || pc.signalingState !== 'stable');

ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return;

Listing 4.4 — The collision test. Only the impolite peer ever sets ignoreOffer.

The second flag, ignoreOffer, records that decision, and it has one further job. Trickled ICE candidates travel behind the description they belong to; if we discarded an offer, the candidates trailing it describe a session we never accepted, and addIceCandidate() will reject them. The pattern swallows addIceCandidate failures precisely when ignoreOffer is true — and only then. Swallowing candidate errors unconditionally is a classic way to hide real bugs.

Interactive minigame

Glare — two peers that renegotiate in the same millisecond

A 3D instrument you drive yourself, one variable at a time. It needs JavaScript and WebGL, so it is not shown in this static copy of the page.

onnegotiationneeded fires late, more than once, and sometimes spuriouslyapi

The event is queued to a microtask-ish checkpoint, so a batch of addTrack calls produces one event — but only if they happen in the same turn. Historically Chrome has also fired it after setRemoteDescription in circumstances where nothing needed negotiating. Because your handler is async, the state you checked before the first await is stale by the time you use it, which is how "we guarded on signalingState" still produces InvalidStateError.

What to do

Use the canonical guard: a makingOffer flag set synchronously at the top of the handler and cleared in a finally, plus await pc.setLocalDescription() with no argument so you never construct a description that can go stale. Re-check pc.signalingState === 'stable' after every await, not before.

4.5.3Rollback without tears

What does the polite peer's surrender actually do? Rollback is a defined transition in JSEP: applying a description of type rollback from either have-local-offer or have-remote-offer returns the connection to stable, undoing the pending offer. Rolling back a local offer disassociates the transceivers that offer had claimed — their mid values clear — but the transceivers themselves, and the tracks you added, remain and still want negotiating. Rolling back a remote offer discards transceivers that were created solely by applying it.

Early perfect-negotiation code had to perform this dance explicitly — setLocalDescription({ type: 'rollback' }), then setRemoteDescription(offer) — two chained operations with their own failure windows. The specification closed the gap with implicit rollback: calling setRemoteDescription() with an offer while in have-local-offer rolls back automatically and then applies the incoming offer, atomically from the application's point of view. The polite peer's entire collision response is one line: await pc.setRemoteDescription(description).

The companion change is parameterless setLocalDescription(). The traditional two-step — const offer = await pc.createOffer(); await pc.setLocalDescription(offer) — contains a race: between the offer being created and it being applied, an incoming offer may have been processed and moved the state to have-remote-offer, at which point applying the now-stale offer throws. Called with no argument, setLocalDescription() decides what to create when it executes on the connection's operations chain: an offer if the state is stable, an answer if it is have-remote-offer. The stale-offer race is not handled — it is abolished. The same handler line therefore serves both duties in the pattern: producing offers in negotiationneeded and producing answers after a remote offer is applied.

One more API rounded out the set: restartIce(). Before it existed, an ICE restart required createOffer({ iceRestart: true }) — but a polite peer's restart offer could be rolled back and the intent lost. restartIce() instead sets internal state that survives rollback: the next offer, whenever it happens, carries new ICE credentials.

4.5.4onnegotiationneeded: firing rules and queuing

The pattern only works because negotiationneeded has disciplined semantics. Three rules matter. First, it fires only in stable. Changes made while negotiation is in progress — a track added mid-handshake — set an internal negotiation-needed flag, and the event is delivered once the state returns to stable. This is exactly how the polite peer's rolled-back changes recover: rollback discarded the offer, not the need; back in stable, the flag is re-checked and the event fires again. (Often not even that is needed — an unassociated transceiver created by addTrack() can be matched to a new m-line in the incoming offer and ride home in the answer.)

Second, firings coalesce. The event is dispatched from a queued task, so adding three tracks synchronously yields one event describing all three changes, not three events. Third, spurious firings must be harmless, and in this pattern they are: the handler's only action is parameterless setLocalDescription(), which simply produces a fresh offer reflecting current state. The corollary is a design rule worth engraving: the negotiationneeded handler is the only place your application ever creates an offer. Code that calls createOffer() from click handlers is code that races itself.

Finally, remember from Lesson 4.4 what renegotiation is not: it reuses the existing ICE and DTLS transports unless you explicitly restart ICE, and replaceTrack() avoids it entirely when swapping media of the same kind. When you must prove that renegotiation actually delivered media — as our problem set does — do not trust connectionState; count flowing inbound-rtp streams with getStats.

Peer A — polite Peer B — impolite addTrack() negotiationneeded setLocalDescription() have-local-offer addTrack() negotiationneeded setLocalDescription() have-local-offer offers cross on the wire offer A offer B offer A arrives ignored (impolite) ignoreOffer = true offer B arrives implicit rollback then sLD() → answer stable answer A sRD(answer A) stable both sides stable — both new tracks flow (two inbound-rtp streams per side)
Figure 4.5 — Crossed offers resolved by role asymmetry. The impolite peer discards the colliding offer and keeps its own; the polite peer rolls back implicitly inside setRemoteDescription() and answers. Zero extra round trips.
Rollback is real, recent, and does not undo your track changesapi

The polite peer's move is await pc.setLocalDescription({ type: 'rollback' }), which returns the connection to stable so the incoming offer can be applied. Firefox implemented it first; Chrome added local rollback around M80 and Safari around 15.4. Critically, rollback restores the signaling state, not your application state: the addTrack that triggered the offer is still there, and the transceivers it created still exist. Implementations have also differed on whether transceiver directions are fully restored.

What to do

After rolling back, expect onnegotiationneeded to fire again for the still-pending change and let the normal flow re-offer. Do not undo application state on rollback; that double-undo is a classic source of vanished tracks.

4.5.5The pattern, whole

Everything above compresses into about thirty lines. Read it once as prose: the negotiationneeded handler makes offers and nothing else; the message handler applies whatever arrives unless it is a colliding offer at an impolite peer; every incoming offer is answered by the same parameterless call that made offers; candidate failures are tolerated only while ignoring.

function perfectNegotiation(pc, signaler, polite) {
  let makingOffer = false;
  let ignoreOffer = false;

  pc.onnegotiationneeded = async () => {
    try {
      makingOffer = true;
      await pc.setLocalDescription();            // offer for current state
      signaler.send({ description: pc.localDescription });
    } catch (err) {
      console.error(err);
    } finally {
      makingOffer = false;
    }
  };

  pc.onicecandidate = ({ candidate }) => signaler.send({ candidate });

  signaler.onmessage = async ({ description, candidate }) => {
    try {
      if (description) {
        const offerCollision = description.type === 'offer' &&
            (makingOffer || pc.signalingState !== 'stable');
        ignoreOffer = !polite && offerCollision;
        if (ignoreOffer) return;                 // impolite: drop it
        await pc.setRemoteDescription(description); // polite: implicit rollback
        if (description.type === 'offer') {
          await pc.setLocalDescription();        // answer for current state
          signaler.send({ description: pc.localDescription });
        }
      } else if (candidate) {
        try {
          await pc.addIceCandidate(candidate);
        } catch (err) {
          if (!ignoreOffer) throw err;           // tolerate only while ignoring
        }
      }
    } catch (err) {
      console.error(err);
    }
  };
}

Listing 4.5 — Perfect negotiation, as canonized in the W3C WebRTC 1.0 Recommendation's negotiation example.

Checkpoint § 4.5Predict the machine

Three quick state-machine predictions before the laboratory.

Check your understanding

Auto-graded check

3 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m4-l5-check1)

A common error

“Glare is rare enough to ignore.” The arithmetic feels persuasive — what are the odds both sides renegotiate in the same 50-millisecond window? — but it assumes the two ends act independently, and they do not. Renegotiations are triggered by a human conversation that synchronizes them: “can you see my screen?” prompts both parties to touch their media at once; a network blip makes both ends react simultaneously; a layout change mutes and unmutes in lockstep. Collisions cluster exactly when your app is busiest. Any design in which both peers can initiate renegotiation will hit glare in production, and code that merely throws in that state strands the call in have-local-offer forever. Perfect negotiation is mandatory engineering, not defensive polish.

Problems for § 4.5Check your understanding

One adversarial laboratory — your negotiation code against a glare injector running one deterministic crossed-offer round and four randomized rounds — and one timeline quiz. The lab takes 15–40 seconds to grade; watch the two peers reconverge to two flowing inbound-rtp streams per side.

Lab 1

Survive the Glare Injector

Graded in the browser against 4 assertions; the editor and harness require JavaScript.

Check your understanding

Auto-graded check

5 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m4-l5-ex2)

Field quirks

What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.

Ignored offers generate addIceCandidate errors you must swallowapi

The impolite peer ignores a colliding offer, but candidates for that ignored offer keep arriving over signaling. Applying them throws — usually OperationError or InvalidStateError. Perfect negotiation's published pattern explicitly rethrows only when ignoreOffer is false, and implementations that skip that detail fill their error dashboards with candidate failures that are entirely expected, which then masks the real ones.

What to do

Keep the ignoreOffer flag and write try { await pc.addIceCandidate(c) } catch (e) { if (!ignoreOffer) throw e }. Count suppressed errors as a metric so you can still see if the rate is abnormal.

replaceTrack avoids glare but can green-frame the remote decoderbrowser

replaceTrack() needs no renegotiation, which makes it the single most effective way to avoid glare — camera switches, mute, screenshare-to-camera all become local operations. The catch is resolution: swapping a 640x480 camera for a 1920x1080 screen capture changes the encoded resolution mid-stream without any signaling, and some Android hardware decoders mishandle the change, producing green or garbage frames until the next keyframe. Kind cannot change: audio for video throws InvalidModificationError.

What to do

Prefer replaceTrack() anyway, but pair large resolution changes with a keyframe request from the receiving side if you control it, or accept a brief artifact. If you own the SFU, force a PLI on detected resolution change.

WebRTC: From First Principles · Module 4, Lesson 5