Signaling State Machine Patterns for WebRTC
Real-time applications demand deterministic connection management. Event-driven code wired directly onto RTCPeerConnection callbacks degrades quickly into race conditions, SDP glare, and unrecoverable media states the moment a network flap, a tab suspension, or a simultaneous renegotiation arrives. A formal finite state machine (FSM) gives you a single source of truth for the connection lifecycle and a place to make recovery decisions explicit instead of emergent. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it walks through building a production-grade signaling FSM step by step: modelling the states, gating SDP transitions against the browserโs own state, abstracting transport, and verifying behaviour under failure.
The goal is a machine whose transitions you can reason about, log, replay, and alert on โ one that drives ICE restarts automatically, tears down cleanly, and never leaves a peer connection stranded in an intermediate state. The two scenarios that break naive implementations most often are transport loss during negotiation and two peers offering at the same time; both are handled here as first-class transitions rather than afterthoughts.
Step 1 โ Model Deterministic State Transitions
Start by mapping the native RTCPeerConnection signals onto a small, explicit application state set: new, connecting, connected, disconnected, and failed. The native API exposes two overlapping enums โ signalingState (stable, have-local-offer, have-remote-offer) and connectionState (new, connecting, connected, disconnected, failed, closed). Your FSM should not try to replace these; it should sit above them and treat their events as inputs. The most consequential modelling decision is how you separate a recoverable disconnected from a terminal failed, a distinction unpacked in Disconnected vs Failed ICE States and one your transition table must encode literally rather than by convention. Decouple UI rendering from FSM state so a brief disconnected blip never flashes an error screen during the interface change described in Handling Wi-Fi to Cellular Network Handover.
The cleanest implementation is a pure reducer: given the current state and an action, return the next state, rejecting any action that is invalid for the current state. This makes out-of-order signaling messages โ a late ANSWER arriving after teardown, a duplicate OFFER โ harmless no-ops instead of exceptions.
// Pure reducer: invalid (state, action) pairs return the current state unchanged
const TRANSITIONS = {
new: { OFFER_SENT: 'connecting', OFFER_RECEIVED: 'connecting' },
connecting: { ICE_CONNECTED: 'connected', ERROR: 'failed' },
connected: { TRANSPORT_FLAP: 'disconnected', CLOSE: 'new' },
disconnected: { ICE_CONNECTED: 'connected', RESTART_TIMEOUT: 'failed' },
failed: { ICE_RESTART: 'connecting', CLOSE: 'new' }
};
function reduce(state, action) {
const next = TRANSITIONS[state]?.[action.type];
if (!next) {
// Reject silently โ out-of-order signaling messages must not throw
console.debug(`[FSM] ignored ${action.type} in ${state}`);
return state;
}
return next;
}
Wire native events into the reducer rather than acting on them directly. The connectionstatechange listener becomes a thin translator that dispatches FSM actions, keeping all decision logic in one auditable place. This indirection is what lets you unit-test the entire lifecycle without a browser: feed the reducer a scripted sequence of actions and assert the resulting states, since the reducer is pure and has no dependency on RTCPeerConnection at all.
// Thin translator: native events become FSM actions, nothing more
let state = 'new';
function dispatch(action) {
const prev = state;
state = reduce(state, action);
if (state !== prev) onTransition(prev, state, action); // single audit hook
}
pc.addEventListener('connectionstatechange', () => {
// Normalise the native enum into the small action vocabulary the FSM understands
switch (pc.connectionState) {
case 'connected': dispatch({ type: 'ICE_CONNECTED' }); break;
case 'disconnected': dispatch({ type: 'TRANSPORT_FLAP' }); break;
case 'failed': dispatch({ type: 'ERROR' }); break;
case 'closed': dispatch({ type: 'CLOSE' }); break;
}
});
Keep the action vocabulary deliberately small. Every action you add is a new edge in the transition table you must reason about, so resist the urge to mirror every native event one-to-one. A handful of high-level actions โ offer sent, ICE connected, transport flap, restart timeout โ covers the entire lifecycle, and anything finer-grained (individual candidate events, gathering-state changes) belongs in instrumentation, not in the state graph itself.
Treat Teardown as a One-Way Door
The CLOSE: 'new' edge in the table above hides an important asymmetry: RTCPeerConnection.close() is irreversible. The native object never leaves closed, so returning the FSM to new is only legal if the accompanying side effect constructs a fresh peer connection. Teams that reuse the old object after a close see every subsequent createOffer reject with InvalidStateError, and because the FSM cheerfully reports new the failure surfaces as a session that simply never starts. Make the reducerโs new state mean โno peer connection exists yetโ and have the transition handler null out the reference, so a stale object cannot be reached at all.
Order the teardown side effects deliberately. Stop every senderโs track, then close the data channels, then close the peer connection โ not the reverse. Closing the connection first destroys the transport before the SCTP association can flush, and any buffered messages are lost silently. Leaving senders attached is worse: a peer connection that is dropped without stopping its tracks keeps the underlying capture device open in Chrome, so the camera indicator stays lit long after the call ended and the next getUserMedia may return the device already in use. Budget 100โ200 ms between stopping tracks and closing the connection so the final RTCP BYE reaches the far end; without it the remote peer waits out its own consent-freshness window before noticing the departure, which can leave a ghost tile on screen for up to 30 s.
Teardown must also be idempotent. A user closing the tab, a CLOSE action from the signaling server, and a fatal error can all arrive within the same task-loop turn, and each will try to run the same cleanup. Guard the handler on the FSM state rather than on a boolean flag โ if the reducer already moved to new, the second CLOSE is a no-op by construction, which is exactly the property the pure-reducer design buys you.
Step 2 โ Gate SDP Sequencing and Rollback
SDP exchange is asynchronous and the browser strictly enforces its own signalingState ladder (stable โ have-local-offer โ stable). Violating that ladder throws InvalidStateError and can leave the connection wedged. Your FSM must serialise negotiation so only one offer/answer cycle is ever in flight, and it must roll back to the last stable baseline whenever a setLocalDescription or setRemoteDescription call rejects.
Process negotiation through a promise-based mutex so concurrent negotiationneeded events queue instead of racing. On any rejection, call pc.setLocalDescription({ type: 'rollback' }) to return to stable, then re-dispatch from a known-good state. These transitions are the practical application of the SDP Offer/Answer Lifecycle, which defines exactly which states accept which descriptions.
let negotiationChain = Promise.resolve(); // serialises all SDP work
function enqueue(task) {
// Chain every negotiation step so two offers never overlap
negotiationChain = negotiationChain.then(task).catch(async (err) => {
console.error('[FSM] negotiation failed, rolling back:', err.message);
if (pc.signalingState !== 'stable') {
// Return to the last stable baseline before retrying
await pc.setLocalDescription({ type: 'rollback' }).catch(() => {});
}
});
return negotiationChain;
}
Traced message by message, a serialised cycle that hits a collision looks like this: the local offer goes out, a colliding remote offer arrives before the answer, the polite side rolls back to stable, applies the remote description, and answers โ one offer/answer cycle in flight throughout.
The single most common failure here is the offer collision โ both peers fire negotiationneeded and offer simultaneously, leaving each in have-local-offer with no path to stable. The full perfect-negotiation recovery, including polite/impolite peer roles, lives in Recovering from Glare in Offer Collisions; the FSMโs job is simply to route a detected collision into a rollback rather than an exception.
Version Every Description with a Negotiation Epoch
Serialising negotiation locally is only half the guarantee. The other half is refusing descriptions that belong to a cycle you have already abandoned. Increment a monotonic epoch counter every time the FSM starts a negotiation, stamp it on the outgoing offer envelope, and require the answer to echo it back. Anything carrying a lower epoch is discarded before it reaches setRemoteDescription.
let epoch = 0; // bumped once per negotiation cycle
let inFlightEpoch = null; // epoch of the offer we are waiting on
async function sendOffer() {
epoch += 1;
inFlightEpoch = epoch;
await pc.setLocalDescription(); // implicit createOffer
transport.send({ type: 'OFFER', epoch, sdp: pc.localDescription.sdp });
}
async function onAnswer(msg) {
// A late answer to a superseded offer must never be applied
if (msg.epoch !== inFlightEpoch) {
console.warn(`[FSM] dropping stale ANSWER epoch=${msg.epoch} want=${inFlightEpoch}`);
return;
}
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
inFlightEpoch = null; // cycle complete
}
The failure this prevents is stale-answer regression, and it is nastier than it sounds because nothing throws. Suppose an ICE restart fires while a slow answer from the previous cycle is still in flight โ plausible whenever the transport has fallen back to long-poll, where a 3โ5 s delivery is normal against the sub-10 ms you get over WebSocket. Applying that older answer overwrites the freshly negotiated ice-ufrag and ice-pwd with the previous pair. The local agent then sends STUN binding requests signed with credentials the remote agent no longer recognises, which answers 401 Unauthorized and drops them. Media keeps flowing on the old candidate pair until consent freshness expires, so the symptom is a call that dies roughly 30 s after the restart, far enough away from the cause that the restart rarely gets blamed. Per the technique in Reading chrome://webrtc-internals Dumps, the tell in the dump is a setRemoteDescription whose a=ice-ufrag matches an earlier entry in the SDP timeline, followed by consentRequests climbing while responsesReceived stays flat.
Epochs also make renegotiation-heavy sessions tractable. Adding a screen-share track, switching cameras, or promoting a receive-only transceiver each triggers a cycle, and under a lossy signaling path several can queue up; only the newest matters. Combining the epoch check with the mutex means the queue collapses to at most one pending cycle, which is the behaviour assumed by SDP Renegotiation Without Dropping Streams when it keeps existing media intact across a description swap.
Step 3 โ Abstract Transport and Buffer ICE
The signaling transport must guarantee ordering, deliver acknowledgements, and degrade gracefully without coupling the FSM to a specific protocol. Hide WebSocket, HTTP long-poll, or RPC behind a narrow interface โ send(msg), onMessage(cb), onClose(cb) โ so the FSM is transport-agnostic and you can swap implementations at runtime.
Attach monotonic sequence IDs to every payload for deduplication and reordering. Buffer ICE candidates that arrive before setRemoteDescription resolves โ the browser silently drops candidates queued more than roughly 500 ms without a remote description โ and flush them in order once it does. When the transport itself drops, apply exponential backoff with jitter and cap reconnection at 5โ7 attempts before escalating to a TURN relay or teardown; never block media threads waiting on signaling recovery. Preserving the room membership and pending message queue across that reconnect is its own problem, worked through in Reconnecting Signaling Sockets Without Losing Session State.
const pendingCandidates = [];
async function onRemoteCandidate(init) {
// Hold candidates until the remote description exists, then apply in arrival order
if (pc.remoteDescription && pc.remoteDescription.type) {
await pc.addIceCandidate(init);
} else {
pendingCandidates.push(init);
}
}
async function flushCandidates() {
while (pendingCandidates.length) {
await pc.addIceCandidate(pendingCandidates.shift());
}
}
For heartbeat framing, reconnection algorithms, and the sub-10 ms delivery characteristics WebSocket gives you, the WebSocket Signaling Implementation guide covers the transport details this abstraction sits on top of. Candidate generation upstream of all this is shaped by ICE Candidate Gathering & Filtering, which determines what the FSM will be buffering in the first place.
The transport interface should expose connection health to the FSM, not hide it. A disconnected from the signaling socket is a different event from a disconnected from the media transport: the first means you cannot send SDP and should buffer, the second means the media path itself flapped and may need an ICE restart. Conflating the two leads to spurious renegotiation โ restarting ICE because the WebSocket briefly dropped, even though the media path was healthy the whole time. Model them as separate inputs so the reducer can react correctly to each. In practice this means the transport adapter emits signalingDown/signalingUp events that drive buffering, while connectionstatechange drives the media-path transitions in the graph above; only the latter ever triggers an ICE restart.
Outbound buffering needs a bound and a discard policy, not just a queue. While signalingDown holds, the FSM keeps producing messages โ local candidates especially, since trickle gathering does not pause because your socket died. An unbounded array will happily accumulate several hundred candidate messages across a 30 s outage on a dual-stack host, and flushing all of them on reconnect delivers a burst the far end must process before it sees anything useful. Cap the queue at roughly 64 messages or 5 s of history, whichever comes first, and collapse it by kind on flush: keep only the newest description per epoch, keep all candidates whose generation matches the current epoch, and drop candidates from superseded generations outright. Those older candidates describe transport addresses the remote agent will never check, so sending them costs a round trip of parsing for no possible gain.
Ordering deserves the same scepticism when the signaling tier is horizontally scaled. A single WebSocket gives you in-order delivery for free, but the moment messages fan out through a broker the guarantee is per-publisher, not per-room โ two peers publishing concurrently can be interleaved arbitrarily at the subscriber. The monotonic sequence IDs above are what let the receiving FSM detect and repair that interleaving, and the broker-side mechanics are covered in Scaling WebSocket Signaling with Redis Pub/Sub. The rule to carry into the reducer is simple: never assume the transport preserved causality, and make every handler tolerate arriving second.
Step 4 โ Verification
Verify the FSM by driving it through each transition deliberately and confirming both the application state and the native connectionState agree at every step. State drift โ your FSM believing it is connected while the browser reports failed โ is the leading cause of silent WebRTC breakage, so make the comparison an explicit assertion rather than a hope. Drive each rehearsal through the same recovery primitive you ship with, issuing the restart the way Triggering an ICE Restart Without Dropping Media describes rather than rebuilding the peer connection.
function assertNoDrift(pc, fsmState) {
// FSM state and native connectionState must stay consistent
const native = pc.connectionState;
const expected = { connecting: ['connecting', 'new'], connected: ['connected'],
disconnected: ['disconnected'], failed: ['failed', 'closed'] };
if (expected[fsmState] && !expected[fsmState].includes(native)) {
console.warn(`[FSM] drift: fsm=${fsmState} native=${native}`); // alert in prod
return false;
}
return true;
}
Run this checklist before shipping:
- Forcing a
failedstate (kill TURN, drop the NIC) triggers exactly one ICE restart and reachesconnecting - Two simultaneous offers resolve to a single
stableconnection with noInvalidStateError -
assertNoDrift
Instrument the FSM with structured transition events (correlation ID, from-state, to-state, monotonic timestamp) emitted to your APM. Set alert thresholds for states exceeding expected durations โ connecting over 5 s, disconnected over 10 s โ and keep a debug mode that logs every transition so you can reconstruct a session from logs. Poll getStats() at 1 s intervals during connecting and disconnected to correlate state with packet loss and RTT.
Replay Transition Logs as Regression Tests
Because the reducer is pure, the transition log you emit for observability doubles as a test fixture. Capture the action stream from real sessions โ action type, epoch, and monotonic timestamp, with SDP bodies stripped โ and store the tail of it alongside crash reports. Any session that ended in failed then becomes a deterministic replay: feed the recorded actions into reduce in a Node test and assert the terminal state. If the replay reproduces the failure, the bug is in your transition table; if it does not, the bug is in a side effect or in the browser, which immediately halves the search space.
// Replay a captured action stream through the pure reducer โ no browser needed
function replay(actions, from = 'new') {
return actions.reduce((s, a) => reduce(s, a), from);
}
// Regression: a flap during negotiation must not strand the machine in 'connecting'
const trace = [
{ type: 'OFFER_SENT' }, // new -> connecting
{ type: 'TRANSPORT_FLAP' }, // ignored: not a legal edge from connecting
{ type: 'ICE_CONNECTED' } // connecting -> connected
];
console.assert(replay(trace) === 'connected', 'flap during negotiation regressed');
Two properties are worth asserting across randomly generated action sequences rather than hand-written traces. First, no sequence of legal actions may reach a state with no outgoing edges other than the intended terminal ones โ a fuzzer that dispatches 10,000 random actions and checks the final state is always in the declared set will find missing edges faster than review will. Second, the machine must be idempotent under repetition: dispatching the same action twice in a row should never produce a different result from dispatching it once, which is what makes duplicate delivery from an at-least-once broker safe. Both checks run in milliseconds because nothing touches a peer connection, so they belong in the pre-commit suite rather than in the browser-based integration tier.
Edge Cases & Browser Quirks
Chromium silent candidate drops. Chrome (and Edge) discard ICE candidates added more than ~500 ms before a valid remote description without throwing, so a missing buffer manifests as connectivity that โsometimesโ fails rather than a clean error. Always buffer, as in Step 3.
Firefox rollback strictness. Firefox honours setLocalDescription({ type: 'rollback' }) but rejects rollback from stable with InvalidStateError; guard the call with a signalingState !== 'stable' check (as shown) or Firefox will turn your recovery path into a new failure.
Safari background suspension. Safari on iOS aggressively suspends RTCPeerConnection ICE activity when the tab backgrounds, surfacing as a disconnected that never recovers on its own. Add a visibilitychange listener that forces an ICE restart on resume rather than waiting for the stack.
connectionState vs iceConnectionState. Older Safari builds (pre-15) lag or omit connectionState; fall back to iceConnectionState and normalise both into your FSM inputs so behaviour is uniform across engines.
Firefox disconnected transience. Firefox surfaces disconnected more eagerly than Chrome during brief packet loss and frequently self-recovers within a second or two. Debounce the connected โ disconnected transition by waiting a short grace window (2โ3 s) before treating it as actionable, or you will trigger ICE restarts on losses the stack would have healed on its own.
Chrome failed is terminal. Once Chrome reports connectionState === 'failed', the only recovery is an ICE restart or a fresh connection; the state will not return to connected on its own. Make failed โ connecting (via ICE restart) the sole non-teardown edge out of failed in your graph, and do not wait for a spontaneous recovery that will never come.
negotiationneeded fires a different number of times per engine. Chrome coalesces the event to once per task-loop turn, so adding an audio and a video track in the same function yields a single event. Firefox has historically fired closer to once per transceiver mutation, and Safari can emit an extra event after setRemoteDescription resolves. Any logic that counts events โ โthe second negotiationneeded means the screen share attachedโ โ is engine-specific by construction. Key the mutex on a boolean needs-negotiation flag that the handler clears when it reaches stable, so N events collapse into one cycle regardless of how many the engine chose to fire.
iOS WKWebView is not Safari. An embedded web view suspends the entire web content process when the host app backgrounds, which is broader than the tab-level ICE suspension in Safari proper: timers stop, so the debounce and restart-timeout clocks you rely on do not fire either. On resume, the elapsed wall-clock gap can exceed every timeout at once, and a naive implementation runs the whole ladder โ flap, timeout, restart โ in a single turn. Compare Date.now() against the last transition timestamp on visibilitychange and, if the gap exceeds about 30 s, skip straight to a restart instead of replaying the intermediate transitions.
Side by side, the four behaviours that most often force engine-specific code look like this โ and each one is absorbed by normalising into the same small action vocabulary rather than by branching on navigator.userAgent.
Common Implementation Mistakes
- Mirroring UI directly to native state. Binding a spinner to
connectionState === 'disconnected'flashes errors on every normal network blip. Drive UI from FSM state, which debounces transient flaps. - Acting in event handlers instead of dispatching. Calling
createOfferstraight fromnegotiationneededraces with in-flight negotiation. Always route through the serialising queue from Step 2. - No rollback on rejection. A rejected
setRemoteDescriptionthat is not rolled back wedges the connection in an intermediate state with no path tostable. Roll back unconditionally on failure. - Unbounded ICE restarts. Looping ICE restarts without a retry cap hammers your TURN servers. Cap at 3 restart attempts before transitioning to
failedand tearing down. - Coupling the FSM to one transport. Hardcoding WebSocket calls inside the reducer blocks the migration to schema-driven RPC. Inject the transport behind an interface.
- Side effects inside the reducer. Calling
pc.restartIce()ortransport.send()from withinreducedestroys replayability and makes the same action produce different results on a retry. Return the next state only; run effects from the transition hook. - Timers that outlive their state. A restart-timeout scheduled on entry to
disconnectedthat is not cleared on the way out fires after recovery and tears down a healthy call. Store the timer id on the state object and clear it in the transition hook for every outgoing edge. - Trusting wall-clock time for timeouts.
Date.now()jumps with NTP corrections and device sleep, so a corrected clock can expire a 10 s window instantly. Measure durations withperformance.now()and reserve wall clock for log correlation only.
FAQ
Why use a state machine instead of RTCPeerConnection event listeners directly?
Direct listeners create implicit, non-deterministic flows that hide race conditions and scale poorly. A formal FSM enforces valid transitions, centralises error handling and rollback, and gives you a single auditable place to trigger ICE restarts and clean teardown โ which is what makes recovery predictable.
How does the FSM avoid full renegotiation after a network flap?
It treats a flap as a connected โ disconnected transition and waits a bounded window for native recovery before issuing a single iceRestart: true offer. The existing RTP/RTCP session and media tracks stay intact while only the transport path is renegotiated, so there is no full teardown.
Can this pattern scale to thousands of concurrent sessions?
Yes, because the reducer is stateless and transport-agnostic. With connection pooling or schema-driven streams behind the transport interface, the FSM logic stays lightweight and runs identically across distributed edge nodes โ the per-session cost is just a small state object.
Should custom FSM state replace pc.signalingState?
No. The native state is authoritative for what SDP operations are legal. Your FSM augments it for application-level decisions; always gate negotiation against pc.signalingState and assert against pc.connectionState to catch drift.
Where should the FSM live in a multi-peer session?
One machine per peer connection, plus a thin room-level coordinator that owns membership and nothing else. Sharing a single machine across peers means one participantโs failed state contaminates the others, and it makes the transition table combinatorial. Per-peer machines keep the table at five states no matter how many participants join; the coordinatorโs only job is to create and destroy machines as peers arrive and leave, and to fan out roster changes.
Does the server need its own copy of the state machine?
It needs a reduced version, not a mirror. The server cannot observe ICE or DTLS progress, so it can only track what it routes: which peers have offered, which have answered, and which epoch is current. That is enough to reject a duplicate offer for an epoch it has already relayed and to garbage-collect rooms whose peers never reached an answer. Trying to model media-path states server-side produces a copy that drifts within seconds, because the authoritative signal โ connectionState โ only exists in the browser.
How long should the disconnected grace window be?
Two to three seconds on desktop, where most flaps are sub-second packet-loss events the stack heals itself. On mobile, where a disconnected usually means a real interface change, a longer window mainly delays recovery, so pair a similar 3 s debounce with an immediate restart when the online event or a network-information change confirms the interface moved. Escalate to failed at around 10 s in both cases; beyond that a restart is cheaper than continuing to wait.
Related: continue with the WebRTC Protocol Stack & Signaling Servers guide, move the transport to typed streams with Custom Signaling Protocols with gRPC-Web, harden the offer/answer race in Recovering from Glare in Offer Collisions, and ground the SDP rules in the SDP Offer/Answer Lifecycle.