The Connection State Machine

An RTCPeerConnection is not a request/response API. It is a state machine. Every step of setup, every network event, and every teardown moves the connection through a defined set of states. Read those states wrong and your code throws InvalidStateError, leaks dead connections, or reports a peer as online long after the link died.

The connection does not expose one state. It exposes four, each tracking a different concern:

Property Tracks Event
signalingState The offer/answer negotiation onsignalingstatechange
iceGatheringState Local candidate discovery onicegatheringstatechange
iceConnectionState The ICE connectivity check result oniceconnectionstatechange
connectionState The aggregate of ICE + DTLS onconnectionstatechange

They run in parallel, not in sequence. A connection can be gathering candidates (iceGatheringState: gathering) while its signaling negotiation is already done (signalingState: stable). Watch the right property for the right question. To ask "did negotiation complete?", read signalingState. To ask "is the peer reachable right now?", read connectionState.

Each property has its own small state graph. The four graphs are coupled (moving signalingState to stable is what lets ICE start, and the outcome of ICE is what drives connectionState), but no single value tells the whole story. This article takes each property in turn, lists every value it can hold, names what triggers entry and exit, then traces three concrete sequences end to end: a clean connect, a glare collision, and a dropped path recovered by ICE restart.

construction time → signalingState iceGatheringState iceConnectionState connectionState negotiating stable gathering complete new checking connected connecting connected ICE + DTLS complete last

The full negotiation that drives these states is covered in Signaling. The ICE path search that feeds iceConnectionState is covered in ICE & NAT Traversal. For reading these properties live in a running app, see Debugging. The full API surface lives in the RTCPeerConnection reference.

signalingState: the negotiation

signalingState tracks where you are in the offer/answer exchange. It is the property that decides which methods you are allowed to call. Calling createAnswer() from stable throws; calling setRemoteDescription(answer) from stable throws. The state is the contract. The browser refuses any transition the current state does not permit, and reports the refusal as an InvalidStateError.

State Meaning Entered when Left when
stable No negotiation in progress. Construction; or an answer is applied on either side. setLocalDescription(offer) or setRemoteDescription(offer) is applied.
have-local-offer Your own offer is set. Awaiting the peer's answer. You call setLocalDescription(offer). You apply the answer with setRemoteDescription(answer), or roll back.
have-remote-offer The peer's offer is set. You owe an answer. You call setRemoteDescription(offer). You apply your answer with setLocalDescription(answer), or roll back.
have-local-pranswer You set a provisional answer. Not final. You call setLocalDescription(pranswer) from have-remote-offer. You set the final answer, or roll back.
have-remote-pranswer The peer set a provisional answer. Not final. You call setRemoteDescription(pranswer) from have-local-offer. You apply the final answer, or roll back.
closed The connection is gone. pc.close() is called. Never. The state is permanent.

The two pranswer states come from provisional answers: a peer signaling that it will likely accept but has not committed the final description. Most applications never produce them and only ever see stable, have-local-offer, and have-remote-offer. They are listed for completeness; the recovery logic below ignores them.

The normal cycle is a loop back to stable. The offerer goes stable → have-local-offer → stable. The answerer goes stable → have-remote-offer → stable. When both sides land back on stable, one negotiation round is done. Renegotiation (adding a track, restarting ICE) starts the loop again from stable.

pc.onsignalingstatechange = () => {
  console.log('signaling:', pc.signalingState);
  if (pc.signalingState === 'stable') {
    // Negotiation round complete. Safe to start another.
  }
  if (pc.signalingState === 'closed') {
    // The connection is finished. Discard references.
  }
};
stable have-local-offer have-remote-offer closed setLocalDescription(offer) setRemoteDescription(answer) / rollback setRemoteDescription(offer) setLocalDescription(answer) / rollback close() pranswer states omitted, rarely produced

negotiationneeded and signalingState

The negotiationneeded event fires when something changes that requires a new offer: adding a track, adding a data channel before the first negotiation, or any change to the connection's media plan. It is the browser saying "the local configuration drifted from what the peer knows; send a fresh offer."

The trap: negotiationneeded can fire while a negotiation is already in flight. If you blindly call createOffer() from inside the handler, you may issue an offer from have-local-offer or have-remote-offer and throw. Gate the handler on signalingState, and guard against re-entry while you are mid-offer.

let makingOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    // setLocalDescription() with no argument creates and sets the offer.
    await pc.setLocalDescription();
    signaling.send({ description: pc.localDescription });
  } catch (err) {
    console.error('negotiation failed:', err);
  } finally {
    makingOffer = false;
  }
};

The makingOffer flag matters for two reasons. It stops a second negotiationneeded from starting an overlapping offer. And it feeds the collision detection below: a peer that is makingOffer and receives a remote offer is in a glare situation.

This site's manual, URL, and ntfy signaling modes do not rely on negotiationneeded for the initial handshake. They build one offer explicitly and exchange it once. The event matters when a live connection renegotiates: for example, an ICE restart, or a future feature that adds a media track to an existing data channel.

Collisions (glare) and rollback

A collision, called glare, happens when both peers call setLocalDescription(offer) at nearly the same time. Each moves to have-local-offer. Then each receives the other's offer. Applying a remote offer requires being in stable, but both sides sit in have-local-offer. Without handling, one or both setRemoteDescription calls throw InvalidStateError, and the negotiation deadlocks.

Rollback breaks the deadlock. setLocalDescription({ type: 'rollback' }) discards the pending local offer and returns signalingState to stable, from which the incoming remote offer applies cleanly. Rollback is a legal signalingState transition (have-local-offer → stable), not an error path.

The perfect negotiation pattern assigns each peer a fixed role (one polite, one impolite) decided ahead of time. On collision, the polite peer rolls back and accepts the incoming offer; the impolite peer ignores the incoming offer and keeps its own.

const polite = /* agreed out of band: e.g. host = false, guest = true */;

signaling.onmessage = async ({ description, candidate }) => {
  try {
    if (description) {
      const offerCollision =
        description.type === 'offer' &&
        (makingOffer || pc.signalingState !== 'stable');

      if (offerCollision && !polite) {
        // Impolite peer: ignore the incoming offer, keep mine.
        return;
      }
      if (offerCollision && polite) {
        // Polite peer: roll back my offer, then accept theirs.
        await pc.setLocalDescription({ type: 'rollback' });
      }
      await pc.setRemoteDescription(description);
      if (description.type === 'offer') {
        await pc.setLocalDescription();        // create + set the answer
        signaling.send({ description: pc.localDescription });
      }
    } else if (candidate) {
      await pc.addIceCandidate(candidate);
    }
  } catch (err) {
    console.error(err);
  }
};

The polite/impolite role assignment and the full reasoning are detailed in Signaling. This site's three signaling modes exchange one fixed offer then one answer, so glare cannot occur in normal operation. Rollback matters once both peers can initiate renegotiation on a live connection.

iceGatheringState: finding local candidates

iceGatheringState tracks the browser's hunt for local candidates: host addresses, plus server-reflexive addresses discovered through STUN. It says nothing about whether the peer is reachable. It only says whether your side has finished listing the routes it could offer.

State Meaning Entered when Left when
new No gathering has started. Construction; or an ICE restart resets gathering. setLocalDescription() starts the agent.
gathering The agent is collecting candidates. onicecandidate fires repeatedly. setLocalDescription() is applied. The agent exhausts every interface and STUN server.
complete Gathering finished. A final onicecandidate fires with a null candidate. The agent has no more candidates to find. An ICE restart resets it for a new round.

Gathering starts when you call setLocalDescription(). Each candidate arrives through onicecandidate; a null candidate marks the end of the round.

pc.onicegatheringstatechange = () => {
  console.log('gathering:', pc.iceGatheringState);
};

pc.onicecandidate = ({ candidate }) => {
  if (candidate) {
    signaling.send({ candidate });          // trickle it to the peer
  } else {
    // null candidate: this gathering round is complete
    console.log('gathering done');
  }
};

You do not have to wait for complete. Trickle ICE sends candidates as they appear, and the partial set gathered in the first second or two is usually enough to connect. This repo's waitIce() in src/salon/peer.js waits for complete but resolves early after a 4 second timeout for exactly this reason, the candidates already gathered are normally sufficient:

// src/salon/peer.js: waits for 'complete', or returns on timeout
export function waitIce(pc, timeoutMs = 4000) {
  if (pc.iceGatheringState === 'complete') return Promise.resolve();
  return new Promise(resolve => {
    const done = () => {
      pc.removeEventListener('icegatheringstatechange', onChange);
      resolve();
    };
    const onChange = () => {
      if (pc.iceGatheringState === 'complete') done();
    };
    pc.addEventListener('icegatheringstatechange', onChange);
    setTimeout(done, timeoutMs);            // partial candidates are usually enough
  });
}

Because this site is STUN-only, gathering produces host and server-reflexive candidates and nothing else. There are no relay candidates because there is no TURN server. After an ICE restart, iceGatheringState runs a second time: it resets and goes gathering → complete again for the new candidates. The candidate types and what each one represents are covered in ICE & NAT Traversal.

new gathering complete setLocalDescription() null candidate fired ICE restart

iceConnectionState: does a path work

iceConnectionState reports the result of ICE connectivity checks: whether any candidate pair actually carries traffic to the peer. This is the property that goes red when the network drops, and the one whose disconnected and failed values drive your recovery logic.

State Meaning Entered when Terminal?
new No checks running yet. Construction; or a restart resets checks. No
checking Candidate pairs are being probed. No working pair confirmed. The agent begins connectivity checks. No
connected At least one pair works. Media and data can flow. A check succeeds. No
completed The best pair is chosen and checks are finished. All checks done, nominee selected. No
disconnected A previously working path stopped responding. Checks on the live pair time out. No, transient
failed All candidate pairs failed. No path exists. Every pair is exhausted with no success. Yes, without intervention
closed The connection was closed. pc.close(). Yes

The difference between connected and completed is subtle and rarely actionable: connected means a working pair exists; completed means checking is finished and the final pair is nominated. Treat both as "the path works."

The difference between disconnected and failed is the one that matters.

disconnected is transient. Connectivity checks stopped getting responses, but the agent has not given up. A brief network hiccup, a Wi-Fi roam, or a few dropped packets can trigger it. The connection often returns to connected on its own within seconds. Do not tear down on disconnected. Show a "reconnecting" indicator and wait.

failed is terminal on its own. Every candidate pair has been exhausted and none works. The connection will not recover without action. From here you either restart ICE or discard the connection.

pc.oniceconnectionstatechange = () => {
  const s = pc.iceConnectionState;
  console.log('ice:', s);

  if (s === 'disconnected') {
    // Transient. Wait: it may return to 'connected' on its own.
    showReconnecting();
  } else if (s === 'connected' || s === 'completed') {
    hideReconnecting();
  } else if (s === 'failed') {
    // Terminal. Restart ICE before giving up.
    pc.restartIce();
  }
};
new checking connected completed disconnected failed begin checks pair works nominated checks time out responses resume consent expires restartIce() closed reachable from any state via close()

disconnected vs failed: timing and recovery

The path from healthy to failed is not instant. The ICE agent runs consent checks on the live pair. When responses stop, the agent enters disconnected first. It keeps probing. If responses resume, it returns to connected. If they do not, after the consent timeout (on the order of tens of seconds, browser-dependent) the agent gives up on every pair and enters failed.

So disconnected is a window, not a verdict. Acting too fast (tearing down on the first disconnected) kills connections that would have healed. Acting too slow (ignoring failed) leaves a dead connection the app still thinks is alive. The correct policy: treat disconnected as "wait, with a UI hint," and treat failed as "recover now."

Recovery uses ICE restart, which keeps the existing data channels and media tracks while renegotiating only the network path. The DTLS session and all application state survive; only the candidates and the chosen pair are replaced. There are two ways to trigger it.

pc.restartIce() is the modern call. It flags the next negotiation to use fresh ICE credentials and fires negotiationneeded, so your existing negotiation handler sends a new offer automatically:

// Modern: flag a restart, let onnegotiationneeded send the offer
function recover() {
  pc.restartIce();
  // onnegotiationneeded fires → setLocalDescription() → signaling.send(...)
}

The older form passes { iceRestart: true } to createOffer(). Use it if you build offers manually instead of relying on negotiationneeded:

// Older: build a restart offer explicitly
async function recover() {
  const offer = await pc.createOffer({ iceRestart: true });
  await pc.setLocalDescription(offer);
  signaling.send({ description: pc.localDescription });
}

Either way the sequence is the same: a new offer crosses the signaling channel, the peer answers, iceGatheringState runs a fresh round, iceConnectionState goes checking again, and a working pair (possibly over a different interface than before) brings the connection back to connected.

Because this site is STUN-only, ICE restart re-runs the host and server-reflexive search. It does not add a relay fallback; there is no TURN server in the path. If both peers sit behind NATs that STUN cannot traverse, a restart finds the same dead end. Restart recovers from a changed or dropped path (a laptop moving from Wi-Fi to a phone hotspot, a router rebooting), not from a topology that has no direct path at all.

connectionState: the single answer

connectionState is the aggregate. It combines iceConnectionState with the DTLS transport state into one value that answers the practical question: is this connection usable right now? For most application logic, this is the only property to watch.

State Meaning Entered when
new Created but idle. Construction.
connecting ICE and/or DTLS are still establishing. ICE checks or the DTLS handshake begin.
connected ICE has a working path and DTLS is secured. Fully usable. Both ICE connects and DTLS completes.
disconnected A transport went transiently disconnected. The underlying iceConnectionState goes disconnected.
failed A transport failed terminally. ICE or DTLS fails with no recovery.
closed The connection is closed. pc.close(), or an unrecoverable failure after teardown.

connectionState reaches connected only after both ICE has a usable path and the DTLS handshake completes. That is why it can lag behind iceConnectionState: connected by a moment. The encrypted transport still has to finish its handshake. It is the truest single signal that the peer link is up and secured.

This is the property setupPeer() in src/salon/peer.js surfaces through onStateChange, and the one the rest of the library treats as the connection's status of record:

// src/salon/peer.js
export function setupPeer({ onStateChange, onChannel } = {}) {
  const pc = new RTCPeerConnection({ iceServers: ICE });
  pc.onconnectionstatechange = () => {
    emit('pc:state', { state: pc.connectionState });
    onStateChange?.(pc.connectionState, pc);
  };
  pc.ondatachannel = (e) => onChannel?.(e.channel);
  return pc;
}

A handler built on connectionState covers the whole lifecycle in one place:

pc.onconnectionstatechange = () => {
  switch (pc.connectionState) {
    case 'connecting':
      showStatus('connecting…');
      break;
    case 'connected':
      showStatus('connected');     // data channel is usable
      break;
    case 'disconnected':
      showStatus('reconnecting…'); // transient: do not tear down
      break;
    case 'failed':
      pc.restartIce();             // try recovery before discarding
      break;
    case 'closed':
      cleanup();                   // drop references; the pc is dead
      break;
  }
};
new connecting connected disconnected failed closed ICE / DTLS begin path + DTLS up transient terminal terminal close() ICE connected AND DTLS complete

How the four relate

The four properties move on a shared timeline but answer different questions, and they change at different rates.

  • signalingState cycles back to stable once per negotiation round. It is done early, long before the peer is reachable, then sits idle until renegotiation.
  • iceGatheringState runs once per ICE start (initial or restart), independent of whether any path works. It is about your candidates, not the peer's reachability.
  • iceConnectionState reflects the live path and changes whenever the network does. It is the most volatile of the four over a connection's life.
  • connectionState rolls ICE plus DTLS into one verdict. It is the one to watch for "is the link up."

The ordering during setup is consistent. signalingState reaches stable first, because negotiation completes before connectivity is confirmed. Gathering reaches complete (or you proceed on partial candidates). iceConnectionState then goes checking → connected. connectionState lands on connected last, after DTLS. After that, signaling and gathering go quiet while iceConnectionState and connectionState track the network for the life of the link.

Sequence 1: a clean connect

Two peers, host and guest. Host creates the offer. Each row is one state change; the columns are the two peers' signalingState / connectionState.

Step Action Host signaling / connection Guest signaling / connection
1 Both peers constructed stable / new stable / new
2 Host setLocalDescription(offer) have-local-offer / connecting stable / new
3 Guest setRemoteDescription(offer) have-local-offer / connecting have-remote-offer / connecting
4 Guest setLocalDescription(answer) have-local-offer / connecting stable / connecting
5 Host setRemoteDescription(answer) stable / connecting stable / connecting
6 ICE checks succeed both sides stable / connecting stable / connecting
7 DTLS handshake completes stable / connected stable / connected

By step 5 both peers are back on stable (negotiation is done), but neither is connected yet. Steps 6 and 7 are the ICE and DTLS work that signalingState never reflects. The data channel onopen fires at step 7, in step with connectionState: connected.

// Host side, distilled
const pc = setupPeer({ onStateChange: s => console.log('host:', s) });
const channel = pc.createDataChannel('game');
channel.onopen = () => console.log('channel open');   // fires at connected

await pc.setLocalDescription(await pc.createOffer()); // → have-local-offer
await waitIce(pc);
signaling.send(pc.localDescription);                  // send offer
const answer = await signaling.recv();
await pc.setRemoteDescription(answer);                // → stable, then connecting → connected
Host (offerer) Guest (answerer) offer answer stable → have-local-offer stable → have-remote-offer → stable → stable ICE + DTLS connected connected connectionState: channel onopen fires

Sequence 2: a glare collision

Both peers initiate at once on a live connection. Host is impolite, guest is polite.

Step Action Host signaling Guest signaling
1 Both setLocalDescription(offer) simultaneously have-local-offer have-local-offer
2 Host receives guest's offer have-local-offer (impolite: ignored) have-local-offer
3 Guest receives host's offer have-local-offer have-local-offer
4 Guest rolls back (polite) have-local-offer stable
5 Guest setRemoteDescription(host offer) have-local-offer have-remote-offer
6 Guest setLocalDescription(answer) have-local-offer stable
7 Host setRemoteDescription(answer) stable stable

The impolite host never deviates: it ignores the inbound offer at step 2 and proceeds as if its own offer is the only one. The polite guest absorbs the collision: it rolls back its own offer (step 4) so the host's offer applies, then answers. Both end on stable with the host's offer winning. The handler that produces this is the perfect-negotiation block shown earlier: the offerCollision test plus the polite/impolite branch.

Hostimpolite Guestpolite host offer guest offer ignores guest offer keeps own offer rollback → stable setRemoteDescription(host offer) → have-remote-offer answer → stable setRemoteDescription(answer) → stable HOST OFFER WINS: BOTH STABLE

Sequence 3: disconnect and ICE restart

A connected link loses its path: a guest's laptop moves from Wi-Fi to a hotspot. Recovery via restartIce() keeps the channel open.

Step Event iceConnectionState connectionState
1 Healthy connected connected
2 Network changes; checks time out disconnected disconnected
3 App waits; UI shows "reconnecting" disconnected disconnected
4 Consent expires, no path recovers failed failed
5 pc.restartIce() → new offer/answer checking connecting
6 New candidate pair works connected connected

The data channel never closes through this sequence. signalingState cycles stable → have-local-offer → stable again during the restart offer/answer at step 5, but the application's channels and state are untouched: the DTLS session is preserved across the restart. The handler:

let downSince = 0;

pc.oniceconnectionstatechange = () => {
  const s = pc.iceConnectionState;
  if (s === 'disconnected') {
    downSince = Date.now();
    showReconnecting();           // wait: may heal on its own
  } else if (s === 'connected' || s === 'completed') {
    downSince = 0;
    hideReconnecting();
  } else if (s === 'failed') {
    console.warn('path failed after', Date.now() - downSince, 'ms');
    pc.restartIce();              // fresh candidates over the new interface
  }
};

If the restart cannot find a path (both peers behind NATs STUN cannot cross), it ends in failed again. There is no relay to fall back to. At that point the only options are to retry the restart later (the network may change again) or to tear down and start a fresh handshake.

connected disconnected failed checking connected transient window restartIce() data channel open, never closes time →

Recap and troubleshooting

  • Four states, four questions. Negotiation: signalingState. Local candidates: iceGatheringState. Live path: iceConnectionState. Overall usability: connectionState.
  • Only call negotiation methods the current signalingState permits. stable is the only state a fresh offer may start from. Gate negotiationneeded on it.
  • Glare is two simultaneous offers. Rollback (have-local-offer → stable) clears it; the polite peer rolls back, the impolite peer ignores. See Signaling.
  • disconnected is a window, not a verdict: wait. failed is terminal: restartIce() or createOffer({ iceRestart: true }), then discard if recovery fails.
  • ICE restart keeps channels and tracks; it renegotiates only the network path. It cannot conjure a path STUN can't find.
  • For most app logic, watch connectionState. It is the single answer.

Symptoms and likely cause:

Symptom Read this Likely cause Action
InvalidStateError on setRemoteDescription signalingState Applying an offer/answer the current state forbids; possible glare. Gate on state; add perfect-negotiation rollback.
InvalidStateError from negotiationneeded signalingState, makingOffer Offering while not stable. Gate the handler; use a makingOffer flag.
Stuck in connecting, never connected iceConnectionState, iceGatheringState No working candidate pair; candidates not exchanged. Confirm trickle / onicecandidate reaches the peer.
Gathering never reaches complete iceGatheringState STUN unreachable or slow. Proceed on partial candidates (timeout); check STUN config.
Was connected, now disconnected iceConnectionState Transient drop or interface change. Wait; show "reconnecting"; do not tear down.
Reached failed connectionState Path exhausted. restartIce(); if it fails again, rebuild.
Restart loops back to failed iceConnectionState No direct path STUN can cross. Retry later or abandon; there is no relay fallback.
Peer shows online after it left connectionState App watching the wrong property. Use connectionState, not signalingState.
Channel closes but connectionState stays connected data channel readyState Channel-level close, not transport. Check channel.readyState; reopen or renegotiate.

To watch all four properties live during a handshake, see Debugging. For the negotiation that drives signalingState, see Signaling. For the candidate search behind iceConnectionState, see ICE & NAT Traversal. The full method and event surface is in the RTCPeerConnection reference.