Signaling: The Out-of-Band Handshake

Two browsers cannot open a peer-to-peer connection until each knows how to reach the other. Signaling is the exchange that gets them there: each side describes its media and network capabilities, and that description has to travel from one browser to the other over a channel WebRTC itself does not provide.

This page is long because signaling sits at the center of every WebRTC application. Get it wrong and nothing connects. Get it right and the rest of the stack (ICE, DTLS, the data channel) follows. The goal here is to leave you able to read a real SDP blob line by line, run the offer/answer cycle by hand, handle collisions without corrupting the state machine, and pick a transport with eyes open.

The problem: browsers cannot find each other

A browser has no public address you can dial. It usually sits behind a NAT, so its local IP is not routable from the internet. Its firewall drops unsolicited inbound packets. Even if you knew where to send the first packet, the receiver would have no idea what it was or how to decode it.

So before any direct connection exists, the two browsers must agree on three things:

  1. Media and data formats. Which codecs each side supports, which data channels exist, and the parameters for each. The intersection becomes the session.
  2. Network candidates. The IP/port pairs each side can be reached on: local addresses, server-reflexive addresses discovered via STUN, and so on.
  3. Security parameters. The keys and certificate fingerprints that will secure the transport, so each side can verify it is talking to the peer it expects.

None of that can travel over the connection being negotiated, because the connection does not exist yet. It has to go over a separate channel. That channel is signaling.

A useful way to frame it: signaling is everything that happens before the peers can talk directly, carried by anything other than the peer-to-peer link. The browser produces the strings. You move them.

WebRTC does not define the signaling channel

This is the part people miss. WebRTC standardizes the media engine and the peer-to-peer transport, but it deliberately leaves the signaling transport undefined. The browser hands you a string. Delivering that string to the other peer is your job.

You can deliver it over a WebSocket, a REST endpoint, a localStorage event between tabs, a QR code on a screen, a URL you text to a friend, a pub/sub topic, or a person reading it aloud. The browser does not care. It only needs the string to arrive intact and be applied in the right order.

This freedom is why no two WebRTC apps signal the same way, and why "WebRTC signaling server" is not a single product you install. It is a design decision you make.

A signaling server, when you use one, never touches media or game data. It relays the setup strings and nothing else. Once the connection is up, traffic flows browser-to-browser and the signaling channel can close. In this repo, signaling may use a server, but gameplay never does: game data moves only over RTCDataChannel, peer to peer, and never transits any server. That constraint shapes every transport described later.

The standard: JSEP

The string-handling rules come from JSEP (JavaScript Session Establishment Protocol). JSEP defines the API surface and the state machine, not the wire transport. The split is intentional: the browser owns the media engine, you own the signaling. That is why you call setLocalDescription and setRemoteDescription yourself instead of the browser doing it for you.

JSEP gives you four methods on RTCPeerConnection and a small state machine that constrains the order you may call them in:

  • createOffer(): produce a description of what this side proposes.
  • createAnswer(): produce a description that responds to an applied remote offer.
  • setLocalDescription(desc): commit a description this side generated.
  • setRemoteDescription(desc): apply a description the peer sent.

The negotiation follows an offer/answer model. One peer (the offerer) proposes a configuration. The other (the answerer) responds with a compatible subset. The shared payload is SDP (Session Description Protocol), a text format the browser generates and parses for you. You rarely hand-write SDP; you move it.

The signaling state machine

Each RTCPeerConnection exposes signalingState. The legal transitions during a normal handshake:

From Call To
stable setLocalDescription(offer) have-local-offer
have-local-offer setRemoteDescription(answer) stable
stable setRemoteDescription(offer) have-remote-offer
have-remote-offer setLocalDescription(answer) stable
have-local-offer setLocalDescription({type:'rollback'}) stable

Calling a method that is illegal for the current state throws InvalidStateError. Most "signaling does not work" bugs are an out-of-order call or a collision that this table forbids. The state machine page covers the full set, including the rollback path used by perfect negotiation below.

The JSEP lifecycle, summarized

Step API call Side Meaning
Create offer createOffer() Offerer "Here is what I support: codecs, transport, keys."
Set local (offer) setLocalDescription(offer) Offerer "I commit to this; start gathering ICE candidates."
Set remote (offer) setRemoteDescription(offer) Answerer "I understand your proposal."
Create answer createAnswer() Answerer "Here is the subset I accept."
Set local (answer) setLocalDescription(answer) Answerer "I commit; start gathering candidates."
Set remote (answer) setRemoteDescription(answer) Offerer "Agreed. Negotiation closed."

After the offerer applies the remote answer, both sides reach the stable signaling state and the connection comes up as ICE candidates pair off.

The offer/answer cycle, step by step

The simple description is "A offers, B answers." Here is each step with the actual calls, so you can run it by hand. Assume both sides created an RTCPeerConnection named pc and have a signal(msg) function that delivers a JSON message to the other peer over whatever transport you chose.

Step 1: Offerer creates the data channel and the offer

The offerer creates the data channel before the offer. Creating the channel is what causes the offer to include an m=application media section. Without it, the offer has nothing to negotiate.

const channel = pc.createDataChannel('game');

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// signalingState: stable -> have-local-offer
// ICE gathering begins automatically now that a local description is set.

createOffer() inspects the connection (the channels and tracks attached) and returns an RTCSessionDescription with type: 'offer' and an sdp string. setLocalDescription commits it. The browser starts gathering ICE candidates the moment a local description exists.

Step 2: Offerer sends the offer

signal({ kind: 'offer', desc: pc.localDescription });

pc.localDescription is the committed description. Serializing it sends { type, sdp }. If you waited for ICE gathering to finish first, the candidates are already inside sdp; if you trickle, you send the offer now and stream candidates separately (covered below).

Step 3: Answerer applies the offer

await pc.setRemoteDescription(offer); // the desc received from step 2
// signalingState: stable -> have-remote-offer

Applying the remote offer tells the answerer's media engine what the peer proposed. It also lets the answerer add any incoming ICE candidates that arrive afterward.

Step 4: Answerer creates and sends the answer

const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// signalingState: have-remote-offer -> stable
// ICE gathering begins on this side too.

signal({ kind: 'answer', desc: pc.localDescription });

createAnswer() produces a description that keeps only what both sides support. If the offer listed three video codecs and this side supports one, the answer names that one.

Step 5: Offerer applies the answer

await pc.setRemoteDescription(answer); // the desc received from step 4
// signalingState: have-local-offer -> stable

Both sides are now stable. As ICE candidates pair off, connectionState moves toward connected, and the data channel's onopen fires. Gameplay can begin.

Peer A(Offerer) SignalingServer Peer B(Answerer) Send Offer (JSON / SDP) Deliver Offer Send Answer (JSON / SDP) Deliver Answer setLocalDescription(offer) setRemoteDescription(offer) setLocalDescription(answer) setRemoteDescription(answer) createOffer() ICE gathering starts automatically createAnswer() ICE gathering starts automatically CONNECTION STATE: STABLE

Reading SDP line by line

The offer and answer are SDP blobs wrapped in an RTCSessionDescription ({ type, sdp }). SDP is line-oriented: each line is key=value, where key is a single letter. The order is fixed by the spec: session-level lines first, then one block per media section. Below is a trimmed offer for a data-channel-only connection (the kind this repo's games use), annotated. Real blobs are longer; the structure is identical.

v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=msid-semantic: WMS
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:F7gI
a=ice-pwd:x9cml/YzichV2+XlhiMu8g
a=ice-options:trickle
a=fingerprint:sha-256 49:66:12:17:0D:...:1C:6A
a=setup:actpass
a=mid:0
a=sctp-port:5000
a=candidate:842163049 1 udp 1677729535 203.0.113.7 54321 typ srflx raddr 192.168.1.5 rport 54321

Line by line:

  • v=0: protocol version. Always 0. Present for historical reasons.
  • o=- 4611731400430051336 2 IN IP4 127.0.0.1: the origin. Fields are username (-, unused), a session id, a session version (bumped on renegotiation), network type, address type, and a unicast address. The address here is a placeholder; ICE supplies the real ones via candidate lines.
  • s=-: session name. WebRTC does not use it, so it is a dash.
  • t=0 0: timing (start/stop). 0 0 means unbounded.
  • a=group:BUNDLE 0: requests that all media sections share one transport. The 0 references the mid of the only section. BUNDLE is why a connection with audio, video, and data still uses a single ICE/DTLS path.
  • a=msid-semantic: WMS: media-stream grouping semantics. Empty here because there are no media tracks.
  • m=application 9 UDP/DTLS/SCTP webrtc-datachannel: the media section. application means a data channel (not audio/video). 9 is a placeholder port. UDP/DTLS/SCTP is the transport stack: SCTP over DTLS over UDP. webrtc-datachannel is the format.
  • c=IN IP4 0.0.0.0: connection address, again a placeholder superseded by ICE candidates.
  • a=ice-ufrag:F7gI: the ICE username fragment. Half of the credential pair used to authenticate connectivity checks.
  • a=ice-pwd:x9cml/YzichV2+XlhiMu8g: the ICE password. The peer's connectivity-check messages are signed with these credentials, so a third party cannot inject candidates.
  • a=ice-options:trickle: signals that this side supports trickle ICE (candidates may arrive after the SDP).
  • a=fingerprint:sha-256 49:66:...:6A: a SHA-256 hash of this side's DTLS certificate. During the DTLS handshake the peer's presented certificate must hash to this value. This is what binds the encrypted transport to the signaled identity. See security.
  • a=setup:actpass: the DTLS role. actpass (offerer) means "I will be client or server, you choose." The answerer replies a=setup:active (it initiates DTLS) or passive.
  • a=mid:0: the media identifier for this section, referenced by the BUNDLE group.
  • a=sctp-port:5000: the SCTP port for the data channel.
  • a=candidate:... typ srflx raddr 192.168.1.5 rport 54321: an ICE candidate. typ srflx is a server-reflexive candidate: the public address (203.0.113.7:54321) that a STUN server observed, with the private address (192.168.1.5) noted as raddr. Each reachable path is one candidate line. What these mean and how they pair off is the subject of ICE & NAT.

For media connections you would also see a=rtpmap lines (payload-type to codec mapping, e.g. a=rtpmap:96 VP8/90000) and a=fmtp lines (codec parameters). The answer keeps only the payload types both sides listed. Inspect a live blob in the SDP inspector, and see the object that wraps it in the RTCSessionDescription reference.

The takeaway: SDP is a contract. It carries capabilities (codecs, data channel), reachability (candidates, ICE credentials), and identity (DTLS fingerprint). Signaling exists to move this contract intact from one browser to the other.

SESSION-LEVEL PER-MEDIA SECTION v=0 o= origin / session id t=0 0 a=group:BUNDLE 0 m=application ... rtpmap a=ice-ufrag / a=ice-pwd a=fingerprint:sha-256 a=setup:actpass a=candidate:... typ srflx ICE authcredential pair DTLS identitycert fingerprint Capabilitiescodecs / data Reachabilityip:port paths

Trickle ICE vs waiting for gathering

ICE candidate gathering takes time: it probes local interfaces and queries STUN servers. You have two ways to get the candidates into the exchange.

Trickle ICE sends the SDP immediately, then streams each candidate to the peer as it is discovered. You listen for the icecandidate event and forward each one; the peer adds it with addIceCandidate. The connection can start forming before gathering finishes.

// Offerer, trickle style
pc.onicecandidate = (e) => {
  if (e.candidate) signal({ kind: 'candidate', candidate: e.candidate });
  // e.candidate === null signals the end of gathering.
};

// Receiving side
async function onSignal(msg) {
  if (msg.kind === 'candidate') {
    try { await pc.addIceCandidate(msg.candidate); }
    catch (err) { /* safe to ignore if buffering before the remote description is set */ }
  }
}

Trickle is the faster path and the right default on a channel that stays open for the whole session (a WebSocket, an SSE stream).

Wait-for-gathering holds the SDP until iceGatheringState reaches complete, so every candidate is embedded in the single blob. Slower, but it collapses the whole handshake into one self-contained message you can ship over a channel that only carries one round trip: a pasted code, a URL, a single published message.

The trickle ICE benefit

On a live signaling channel, do not wait for ICE gathering to finish before sending your offer. Send the offer immediately, then trickle the network paths (candidates) as they arrive. This can shave seconds off connection time.

This repo's manual and URL transports take the second approach on purpose: each one delivers a single self-contained blob, so they wait for gathering, with a ~4 s timeout so a slow STUN probe never stalls the handshake. The helper is waitIce(pc, timeoutMs) in src/salon/peer.js, which resolves on iceGatheringState === 'complete' or after the timeout, whichever comes first. Returning early is fine; the candidates already gathered are usually enough to connect.

// src/salon/peer.js: resolves on complete, or after timeoutMs
export function waitIce(pc, timeoutMs = 4000) {
  if (pc.iceGatheringState === 'complete') return Promise.resolve();
  return new Promise(resolve => {
    const timer = setTimeout(() => { cleanup(); resolve(); }, timeoutMs);
    const onChange = () => {
      if (pc.iceGatheringState === 'complete') { cleanup(); resolve(); }
    };
    function cleanup() {
      clearTimeout(timer);
      pc.removeEventListener('icegatheringstatechange', onChange);
    }
    pc.addEventListener('icegatheringstatechange', onChange);
  });
}

Perfect negotiation: glare and rollback

The step-by-step cycle above assumes one fixed offerer. Many apps let either side start negotiating, and then both can fire an offer at the same instant. That collision is glare, and naively applying two offers throws the signaling state machine into an illegal transition (InvalidStateError).

The perfect negotiation pattern resolves glare without scattering app-specific branches through your code. Assign each peer a role, decided once and out of band:

  • The polite peer yields on collision. If a remote offer arrives while it has an outstanding local offer, it rolls back its own offer and accepts the remote one.
  • The impolite peer wins. It ignores a colliding incoming offer and keeps its own.

Roles must be agreed before negotiation: for example, the side that created the room is impolite, the side that joined is polite. Both sides run the same event-driven code; the only difference is the polite boolean.

// Perfect negotiation: identical on both sides except `polite`.
// `signal(msg)` delivers to the peer; `onSignal` receives.

let makingOffer = false;
let ignoreOffer = false;
let isSettingRemoteAnswerPending = false;

// 1. Renegotiation trigger. Fires when tracks/channels change.
pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();          // implicit createOffer + commit
    signal({ description: pc.localDescription });
  } catch (err) {
    console.error(err);
  } finally {
    makingOffer = false;
  }
};

// 2. Trickle candidates outward.
pc.onicecandidate = (e) => {
  if (e.candidate) signal({ candidate: e.candidate });
};

// 3. Handle everything the peer sends.
async function onSignal({ description, candidate }) {
  try {
    if (description) {
      // Are we in a state where an incoming offer would collide?
      const readyForOffer =
        !makingOffer &&
        (pc.signalingState === 'stable' || isSettingRemoteAnswerPending);
      const offerCollision = description.type === 'offer' && !readyForOffer;

      // Impolite peer ignores the collision; polite peer yields.
      ignoreOffer = !polite && offerCollision;
      if (ignoreOffer) return;

      isSettingRemoteAnswerPending = description.type === 'answer';
      await pc.setRemoteDescription(description); // rolls back implicitly if needed
      isSettingRemoteAnswerPending = false;

      if (description.type === 'offer') {
        await pc.setLocalDescription();          // implicit createAnswer + commit
        signal({ description: pc.localDescription });
      }
    } else if (candidate) {
      try {
        await pc.addIceCandidate(candidate);
      } catch (err) {
        // An ignored offer means we discarded its candidates too. Safe to swallow.
        if (!ignoreOffer) throw err;
      }
    }
  } catch (err) {
    console.error(err);
  }
}

How the collision resolves, concretely:

  • No collision. A remote offer arrives while signalingState is stable and makingOffer is false. readyForOffer is true, so it is applied normally and answered.
  • Collision, impolite side. A remote offer arrives while this side has an outstanding local offer. offerCollision is true and polite is false, so ignoreOffer becomes true and the handler returns. This side's own offer survives.
  • Collision, polite side. Same situation, but polite is true, so the offer is not ignored. setRemoteDescription on a peer that has a local offer performs an implicit rollback: the local offer is discarded, the connection returns to stable, and the remote offer is applied. The polite side then answers. Both sides converge on the impolite side's offer.

The implicit rollback is the key. You do not call setLocalDescription({ type: 'rollback' }) by hand here: modern browsers roll back automatically when you set a remote offer over an outstanding local offer. The explicit rollback type still exists for cases where you abandon a negotiation without applying a remote one. The state machine page walks the rollback transition in full.

Note the modern setLocalDescription() with no argument: it implicitly calls createOffer() or createAnswer() depending on the current state and commits the result. That is why the same handler produces both offers and answers.

Polite peerjoined the room Impolite peercreated the room GLARE: BOTH OFFER AT ONCE offer offer rolls back local offeraccepts the remote one ignores inbound offerkeeps its own answer (to impolite's offer) setRemoteDescription(answer) CONVERGED both signalingState = stable, on the impolite offer

Renegotiation

The first handshake is rarely the last. Whenever the set of media or data changes, the connection must renegotiate. Triggers include:

  • adding or removing a media track (pc.addTrack / pc.removeTrack),
  • starting a screen share, which adds a video track,
  • creating a data channel after the connection is already up,
  • changing the direction of an existing track (send-only to send/receive).

Each of these fires negotiationneeded. You run the offer/answer cycle again over the same signaling channel. The connection stays up; only the description changes, and the session version in the o= line bumps. Existing data channels and tracks keep flowing during renegotiation.

// Adding a screen share after connect triggers renegotiation.
const screenStream = await navigator.mediaDevices.getDisplayMedia();
for (const track of screenStream.getTracks()) {
  pc.addTrack(track, screenStream); // fires negotiationneeded
}
// The onnegotiationneeded handler from the perfect-negotiation block
// creates a new offer and signals it. The peer answers. Done.

Perfect negotiation matters most here, because either side may trigger a renegotiation at any time, including both at once. The same handler that bootstraps the first connection handles every later one. This is the main reason to adopt perfect negotiation even for an app that starts with a single fixed offerer: the moment a feature lets the other side add a track, you need collision handling.

This repo's games negotiate once and keep a single data channel, so they do not renegotiate in practice. The pattern still applies the moment you add media, for example a voice channel layered on top of a game.

The three transports this repo ships

The wire protocol and game code are identical across all three transports. Only the delivery of the SDP string differs. All three are STUN-only and never relay media or game data. The single-blob transports (manual, URL) wait for ICE gathering via waitIce so the blob is self-contained; the pub/sub transport can stream.

1. Manual paste codes: src/salon/handshake.js

No server at all. The SDP is serialized to a string and the two players copy/paste it between browsers by whatever means they like: chat, email, in person, a sticky note. The module is three functions.

import { createInvite, acceptInvite, completeInvite } from './salon/handshake.js';

// Host, step 1: build the offer code.
const { code, channel } = await createInvite(pc);
//  code === JSON.stringify(pc.localDescription) after ICE gathering (or the ~4 s timeout)
//  -> send `code` to the guest by any means

// Guest: apply the offer, return the answer code.
const answer = await acceptInvite(pc, offerCode);
//  -> send `answer` back to the host

// Host, step 2: apply the answer. The data channel opens.
await completeInvite(pc, answer);

Internally createInvite creates the data channel (so the offer carries an m=application section), builds and commits the offer, calls waitIce(pc, 4000), then returns JSON.stringify(pc.localDescription) as the code. acceptInvite applies the offer with setRemoteDescription, creates and commits the answer, waits for ICE, and returns the serialized answer. completeInvite applies it with setRemoteDescription.

For three-player games (worms), the host side uses peerPool(2) in src/salon/peer-pool.js: one RTCPeerConnection per guest, indexed by slotIdx, each running the same inviteSlot / acceptAnswer handshake. There is no peer-to-peer mesh; guests only talk to the host.

  • Good for: zero infrastructure, full control, debugging, demonstrating the raw handshake.
  • Tradeoff: two manual round trips and long, ugly blobs. Awkward for non-technical players.

2. URL-hash: src/salon/url-handshake.js

The same SDP, but compressed and packed into a URL fragment so players share a link instead of pasting JSON. encode() gzips the string with the native CompressionStream and base64url-encodes the bytes; decode() reverses it with DecompressionStream.

import {
  encode, decode, makeShareUrl, readOfferFromUrl, readAnswerFromUrl, extractBlob,
} from './salon/url-handshake.js';

// Host: turn the committed offer into a shareable link.
const blob = await encode(JSON.stringify(pc.localDescription));
const url  = makeShareUrl('h', blob);   // <page>#h=<blob>
//  -> send `url` to the guest

// Guest: read the offer from the link they opened.
const offerJson = await decode(readOfferFromUrl());
await pc.setRemoteDescription(JSON.parse(offerJson));
// ...build the answer, then share it back as #a=<blob>:
const answerBlob = await encode(JSON.stringify(pc.localDescription));
const answerUrl  = makeShareUrl('a', answerBlob);

The fragment uses #h= for the offer and #a= for the answer. extractBlob(raw) is forgiving: it accepts a full URL, a bare #h=... fragment, or a raw base64url blob, so a player can paste any of them. setHashParam drops the opposite half of the handshake to keep the URL clean, and clearHash removes the fragment after connect.

The fragment lives after the #, so it never reaches a server: the browser does not send the fragment in HTTP requests. The SDP stays client-side even though it travels in a URL.

  • Good for: sharing over any link-capable channel (chat, QR code) with no backend.
  • Tradeoff: URLs have length limits, which is exactly why the SDP is gzipped first. Still two round trips, since both the offer link and the answer link must be exchanged.

3. ntfy.sh pub/sub: src/salon/ntfy.js

Zero-backend signaling through the public ntfy.sh network. Each room maps to a topic (webrtc-<room>); peers publish their SDP to it and subscribe for the other side's. No server you run, no account, just HTTP.

import {
  publishSignal, subscribeSignal, pollSignal, generateRoomCode,
} from './salon/ntfy.js';

const room = generateRoomCode(); // share this short code with the other player

// Host: publish the offer, subscribe for the answer.
await publishSignal(room, 'offer', pc.localDescription);
subscribeSignal(room, 'answer', async (answer) => {
  await pc.setRemoteDescription(answer);   // connection comes up
});

// Guest: read the offer, publish the answer.
const offer = await pollSignal(room, 'offer');
await pc.setRemoteDescription(offer);
// ...build the answer...
await publishSignal(room, 'answer', pc.localDescription);

publishSignal POSTs { type, payload } JSON to the topic. pollSignal fetches recent messages once over the ?poll=1 JSON endpoint and returns the first payload of the expected type. subscribeSignal opens an EventSource (SSE) and closes it after the expected message arrives, so it works for the live "wait for the other side" half of the handshake. generateRoomCode produces a random short code so two strangers can connect by sharing only that code.

  • Good for: connecting two people who share nothing but a short room code; no setup, no manual paste.
  • Tradeoff: depends on a third-party relay being up, and topics are public. Anyone who learns the room code can read the SDP off the topic. The SDP exposes ICE candidates and the DTLS fingerprint, not media, but it is still not private. Treat room codes as secrets and keep them short-lived.

Transport comparison

Manual paste URL-hash ntfy.sh pub/sub
Module handshake.js url-handshake.js ntfy.js
Backend required None None Third-party relay
What the player shares JSON blob A link A short room code
SDP delivery Copy/paste both ways Two links (#h=, #a=) Publish/subscribe by topic
ICE strategy Single blob (waitIce) Single blob (waitIce) Can stream or single-shot
Round trips 2 manual 2 manual Automatic once code is shared
Privacy of SDP Stays with the players Fragment never hits a server Public topic: readable by anyone with the code
Best when Debugging, full control Sharing over any link channel Two people, one short code, no setup
Main limitation Long blobs, manual steps URL length (mitigated by gzip) Relay dependency, public topics

All three feed the identical wire protocol and game code. Switching transports does not touch a line of gameplay logic. That is the point of keeping signaling out of band.

Recap and troubleshooting

Signaling carries the offer/answer pair so two browsers can agree on capabilities, reachability, and identity before any direct connection exists. WebRTC defines JSEP and the SDP payload; you choose the transport. SDP is a contract: m=/rtpmap for capabilities, ice-ufrag/ice-pwd/candidate for reachability, fingerprint/setup for identity. Trickle candidates on a live channel; ship a single self-contained blob on a one-shot channel. Perfect negotiation absorbs glare with role-based rollback. Renegotiation reruns the cycle whenever tracks or channels change.

Common failures and where to look:

  • Stuck before connect, no answer applied. The signaling channel dropped the offer or answer. Confirm the exact string arrived intact and that setRemoteDescription ran without throwing. Log signalingState on both sides.
  • InvalidStateError on setLocalDescription/setRemoteDescription. You applied descriptions out of order, or two offers collided. Check the state-machine table above. If either side can initiate, adopt perfect negotiation.
  • Glare under load. Both sides offered at once and neither yielded. Confirm exactly one side is polite and one is impolite, decided before negotiation, not by chance.
  • Handshake completes but the connection never reaches connected. Signaling worked; ICE did not find a working path. That is a candidate/NAT problem, not a signaling one. See ICE & NAT.
  • Empty offer with no m= section. You called createOffer() before creating the data channel or adding a track. There was nothing to negotiate. Create the channel first.
  • Truncated or mangled blob. A transport with length or character limits corrupted the SDP. The URL transport gzips precisely to stay inside fragment limits; if you carry SDP elsewhere, preserve it byte-for-byte.
  • Slow first connect. Waiting for full ICE gathering on a channel that could trickle. Trickle candidates on a live channel, or accept the ~4 s waitIce timeout the single-blob transports use.
  • Candidates rejected with no remote description. You called addIceCandidate before setRemoteDescription. Buffer incoming candidates until the remote description is set, or rely on the implicit handling in the perfect-negotiation block.

Collisions and rollback live in the state machine. What signaling exposes about the peer (the DTLS fingerprint and ICE credentials) is covered in security. What the candidates mean and how they pair off is in ICE & NAT. Read a raw description in the SDP inspector, and see the wrapper object in the RTCSessionDescription reference.