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.
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.
}
};
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.
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();
}
};
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;
}
};
How the four relate
The four properties move on a shared timeline but answer different questions, and they change at different rates.
signalingStatecycles back tostableonce per negotiation round. It is done early, long before the peer is reachable, then sits idle until renegotiation.iceGatheringStateruns once per ICE start (initial or restart), independent of whether any path works. It is about your candidates, not the peer's reachability.iceConnectionStatereflects the live path and changes whenever the network does. It is the most volatile of the four over a connection's life.connectionStaterolls 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
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.
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.
Recap and troubleshooting
- Four states, four questions. Negotiation:
signalingState. Local candidates:iceGatheringState. Live path:iceConnectionState. Overall usability:connectionState. - Only call negotiation methods the current
signalingStatepermits.stableis the only state a fresh offer may start from. Gatenegotiationneededon it. - Glare is two simultaneous offers. Rollback (
have-local-offer → stable) clears it; the polite peer rolls back, the impolite peer ignores. See Signaling. disconnectedis a window, not a verdict: wait.failedis terminal:restartIce()orcreateOffer({ 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.