Production-grade WebRTC for Node.js & Browsers
WebRTC connections that survive the real world — glare, network chaos, oversized SDPs, and all.
A WebRTC demo connects two browsers in 20 lines. A WebRTC product fights these battles daily:
| Issue | What happens | How often |
|---|---|---|
| Glare | Both peers send an offer at the same time. The native API deadlocks. | Every call with camera + screenshare |
| Signal reordering | An ICE candidate arrives before the offer it belongs to. Connection fails silently. | Any non-WebSocket transport |
| Renegotiation storms | User switches camera while a previous negotiation is still in-flight. SDP state corrupts. | Camera/mic toggle, screenshare start/stop |
| SDP bloat | 4KB+ of SDP text fragments across packets, slowing or breaking the handshake. | Simulcast, multiple codecs, many transceivers |
| Network roaming | User walks from Wi-Fi to LTE. ICE candidates expire. Call drops. | Mobile, every day |
| No disconnect detection | The native API gives you iceConnectionState and nothing else. No events for "reconnecting" or "recovered". Building a reconnection UI requires polling and guesswork. |
Every app with a status indicator |
| No stream telemetry | Want to show bitrate, packet loss, or resolution per stream? You have to poll getStats(), parse 30+ report types, compute deltas yourself, and track which stats belong to which stream by MID. |
Every app with quality indicators |
| DataChannel backpressure | Burst of messages fills the buffer. App freezes. No error. No warning. | File transfer, game state sync |
| Tab backgrounding | Mobile OS suspends the tab. Timers stop. State goes stale. Peer thinks you left. | Mobile browsers |
stable-webrtc handles all of this internally. You get a clean API — the library handles the rest.
var peer1 = new StableWebRTC();
var peer2 = new StableWebRTC();
// Relay signaling (in production, use WebSocket / HTTP / MQTT / anything)
peer1.on('signal', data => peer2.signal(data));
peer2.on('signal', data => peer1.signal(data));
// Connected — send data
peer1.on('connect', () => peer1.send('Hello from peer1!'));
peer2.on('data', msg => console.log('Received:', msg.toString()));That's it. No role assignment, no ICE configuration, no state machine — it just works.
The browser's WebRTC API (RTCPeerConnection) is a low-level protocol binding, not an application API. Here's what stable-webrtc fixes:
The native API gives you onnegotiationneeded and expects you to handle the case where both peers send an offer simultaneously. The MDN "perfect negotiation" pattern is 40 lines that doesn't cover all edge cases. stable-webrtc uses a 6-state machine with epoch-rotating politeness — one side always yields, and who yields alternates to prevent starvation.
setLocalDescription({type:'rollback'}) behaves differently between Chrome and Firefox. Chrome can corrupt BUNDLE group state during implicit rollback. stable-webrtc always uses explicit rollback with a serialized callback queue, ensuring only one rollback is ever in-flight and working around browser-specific bugs.
If addIceCandidate() is called before setRemoteDescription(), it silently fails. stable-webrtc queues candidates per-ufrag, sorts by priority, deduplicates, and drains them after the remote description is applied.
If both peers create transceivers simultaneously, m-line ordering in the SDP conflicts. stable-webrtc ensures only the offering side creates transceivers (right before createOffer()), recycles inactive ones instead of stopping them, and automatically cleans up orphaned transceivers after 1.5 seconds.
The native API gives you raw ICE state strings. Building a "reconnecting..." UI requires polling iceConnectionState, debouncing transitions, and guessing. stable-webrtc gives you disconnect and reconnect events with zero configuration.
Getting bitrate for a specific video stream requires polling getStats(), iterating 30+ report types, matching by MID, computing byte deltas over time, and correlating inbound/outbound reports. stable-webrtc does this internally and gives you streamstats events with { bitrate, fps, packetLoss, jitter, codec } per tagged stream.
A simple DataChannel-only offer is 2–3KB of text. Adding video makes it 4KB+. stable-webrtc compresses the initial offer to ~80 bytes and renegotiation diffs to ~50 bytes using four competing compression strategies. ICE candidates are stripped from SDPs and sent separately as ~20-byte binary packets.
The native API forces you to manually relay offers, answers, and candidates through your own transport. stable-webrtc takes binary Uint8Arrays — pipe them through anything (WebSocket, HTTP, MQTT, SMS). After the DataChannel opens, signaling automatically routes through the peer-to-peer connection itself.
By default, host candidates (containing local addresses like 192.168.x.x) are sent — they're what lets two peers on the same machine or LAN find a direct local path, so dropping them silently breaks same-network connections. If you'd rather not expose internal network topology through your signaling relay, set exclude_host_candidates: true and only server-reflexive and relay candidates are transmitted. You choose the tradeoff explicitly instead of having connectivity quietly removed.
The native API fires onicecandidate with null when gathering completes, but there's no built-in way to tell the remote peer "I'm done sending candidates." The remote side has to guess using timeouts. stable-webrtc sends an explicit candidate count per ufrag, so the receiver knows exactly when all candidates have arrived and signals the ICE agent immediately.
Every new RTCPeerConnection() generates a fresh DTLS certificate if you don't provide one. This takes 50–200ms and blocks the constructor. If you're creating multiple connections (mesh network, reconnections), this adds up. stable-webrtc pre-generates a shared ECDSA P-256 certificate once and reuses it across all instances, with automatic renewal on expiry.
The native API trusts whatever you feed to setRemoteDescription and addIceCandidate. If your signaling transport corrupts a byte (UDP packet damage, WebSocket frame issue), the connection silently fails with an opaque error. stable-webrtc wraps every signaling message in a MurmurHash3 checksum — corrupt messages are dropped before they can poison the state machine. Nonce verification prevents stale or injected signals from being processed.
Node.js / bundlers (ESM)
npm install stable-webrtcimport StableWebRTC from 'stable-webrtc';Browser (prebuilt bundle)
<script src="dist/stable-webrtc.browser.js"></script>
<!-- exposes window.StableWebRTC -->The package ships as ES modules (src/ split into focused modules; index.js is the entry).
A prebuilt IIFE bundle for direct <script> use is produced by npm run build:browser.
Node.js requires a WebRTC binding:
import wrtc from '@roamhq/wrtc';
const peer = new StableWebRTC({ wrtc: wrtc });Dependencies: two tiny zero-dependency libraries —
compact-delta(SDP delta encoding) andlitepack(binary wire schemas).
Both peers embed a random nonce in every signaling message. The peer with the higher nonce creates the DataChannel and sends the first offer. The other peer waits. No configuration needed — roles are locked before any SDP is generated, eliminating DataChannel glare and m-line ordering conflicts at the source.
To further reduce glare during startup, each peer waits a random 8–63ms before creating its DataChannel. This staggering ensures that in most cases, only one peer initiates before the nonce exchange resolves who should lead.
All negotiations pass through a 6-state machine:
STABLE (0) → MAKING_OFFER (1) → WAITING_FOR_ANSWER (2) → APPLYING_ANSWER (3) → STABLE
↑
STABLE (0) → HANDLING_REMOTE_OFFER (4) → WAITING_FOR_DONE (5) ─────────────────────┘
Only one negotiation can be in-flight at a time. If a new track is added while a negotiation is active, it queues and coalesces. Glare is resolved via epoch-rotating politeness — each successful negotiation flips who yields, preventing starvation.
Adaptive answer timeout: When waiting for an answer, the timeout starts at 7 seconds but adjusts based on historical round-trip times. If previous answers took 5 seconds, the timeout extends to match. If no answer arrives in time, the library rolls back and retries automatically.
Serialized rollback: Only one rollback can be in-flight at a time. If multiple code paths request a rollback simultaneously (e.g., glare + timeout), they queue and get notified in order when the rollback completes. This prevents Chrome's BUNDLE state corruption that can occur during concurrent rollback attempts.
Every offer and answer is compressed through four competing strategies. The smallest payload wins:
| Strategy | Best for | Typical saving |
|---|---|---|
| Compact | First offer (DataChannel only) | 70–80% — binary encoding of SDP fields |
| Diff | Renegotiations | 90–97% — only the changed lines |
| Deflate | Large SDPs (simulcast, many codecs) | 50–60% |
| Diff + Deflate | Moderate changes on large SDPs | 85–95% |
Each payload includes a MurmurHash3 checksum. If decompression fails (hash mismatch), the receiver automatically requests a raw retransmit — no manual intervention needed.
Why this matters: A typical renegotiation (adding a video track) produces a 3KB SDP. With diff compression, the signaling message is 50–100 bytes — small enough for a single UDP packet, an MQTT message, or even an SMS.
ICE candidates are always stripped from the SDP before setLocalDescription. They're sent separately using binary compression (see below). This design choice enables the SDP compression engine to work effectively — embedded candidates add ~1KB of unpredictable text that defeats diff compression.
Once the DataChannel is open, signaling messages (offers, answers, candidates) automatically route through it instead of your external transport. This cuts renegotiation latency to the raw RTT of the peer-to-peer connection. If the DataChannel drops, routing falls back to external signaling transparently.
Every signaling message is wrapped with a MurmurHash3 checksum and the sender's nonce. On receipt:
- Checksum mismatch → message is silently dropped (corrupt or truncated in transit).
- Nonce mismatch → message is silently dropped (prevents signal injection from a third party or a stale connection).
This protects against unreliable transports (UDP, lossy WebSocket) and ensures only the intended peer's messages are processed.
Each ICE candidate is encoded from ~150 bytes of text into ~20–30 bytes of binary using bit-packed fields (transport, type, priority, IP, port, related address). Candidates are deduplicated by ufrag, sorted by priority, and trickled in optimal order.
By default all candidate types — including host candidates (local addresses like 192.168.x.x) — are sent, because host candidates are what let two peers on the same machine or LAN establish a direct local path. Setting exclude_host_candidates: true transmits only server-reflexive (srflx), peer-reflexive (prflx), and relay candidates, preventing local-IP exposure through the signaling channel at the cost of same-network direct connectivity. The default favors connectivity; the option lets privacy-sensitive deployments opt out.
The library's own signaling (offers, answers, candidates, stream maps) is split into chunks when a message exceeds the per-pipe size limit, then reassembled transparently on the other side — so oversized SDPs (simulcast, many codecs) never exceed a transport's message-size cap. This applies to both pipes: the external signal transport and the internal DataChannel (SCTP). The limit is max_signal_chunk_size (default 1 KB); the internal pipe uses min(max_signal_chunk_size, sctp.maxMessageSize). Reassembly is all-or-nothing with a lazy timeout — on the library's unreliable/unordered DataChannel a lost chunk just fails reassembly and the message-level logic re-sends. Application data (peer.send) is never chunked — that remains the developer's responsibility. Small messages travel with a single byte of overhead, so the common case pays almost nothing.
Reassembly is bounded by defensive limits so a malformed or malicious stream can't exhaust memory: each chunk reserves 16 bytes for its header (so the actual payload per chunk is max_signal_chunk_size − 16); partial reassemblies are dropped after 5 s (CHUNK_REASSEMBLY_TIMEOUT); at most 16 partial messages may be in flight at once (CHUNK_MAX_OPEN); and any message claiming more than 65536 chunks (CHUNK_MAX_TOTAL) is rejected outright. These are internal constants — max_signal_chunk_size is the only one you configure.
The MEDIASTREAM_MAP (which tells the remote side how MIDs map to tagged streams) is delivered with an ACK + retransmit scheme, since it travels over the unreliable DataChannel. The receiver ACKs every copy (including duplicates); the sender retransmits with backoff until acknowledged, so a dropped map can't leave the two peers with mismatched stream associations.
When ICE gathering completes, the library sends the total candidate count per ufrag to the remote peer. The receiver tracks how many candidates it has processed and calls addIceCandidate(null) when all have arrived. This signals the browser's ICE agent that gathering is complete, enabling faster candidate pair selection instead of waiting for the default gathering timeout.
The library drives ICE-restart decisions from a single source of truth — iceConnectionState (the correct signal for ICE-level recovery):
disconnected→ starts a configurable timer (default 3s). If the connection doesn't recover, triggers ICE restart.failed→ immediate ICE restart.connected→ clears timer, resets retry counter.
Up to 5 retries with backoff. No manual intervention needed — network roaming (Wi-Fi → LTE) is handled transparently. The disconnect and reconnect events let your UI react immediately.
When a track is removed, its transceiver is set to inactive (not stopped). The m-line stays in the SDP at its original position, preserving ordering. When a new track of the same kind is added later, the inactive transceiver is reused — no new m-line, no renegotiation overhead. Transceivers are created only by the offering side, right before createOffer(), preventing index conflicts during glare.
Automatic cleanup: Transceivers that are no longer mapped to any logical stream are cleaned up after 1.5 seconds. This prevents dead m-lines from accumulating in the SDP over long sessions with many add/remove cycles.
Each stream is identified by a user-defined tag (e.g., 'camera', 'screen'). The library sends a sequenced MEDIASTREAM_MAP message that maps tag IDs to SDP MIDs. The receiving peer uses this map to associate incoming transceivers with logical streams — so it knows that MID 1 is camera video and MID 2 is camera audio. The sequence number prevents out-of-order map updates from corrupting the mapping.
If both peers create a DataChannel before the nonce exchange resolves roles (a race condition during startup), multiple DataChannels may end up open. The library detects this and picks a winner — the channel with the lowest SID (SCTP stream identifier). All other channels are closed immediately. This prevents duplicate message delivery and m-line ordering conflicts.
The library pre-generates a single ECDSA P-256 certificate and shares it across all StableWebRTC instances on the same page. This eliminates the ~50–200ms certificate generation latency that normally blocks each new RTCPeerConnection. The certificate is cached until expiry and regenerated automatically.
Bounded queues with dual quotas (messages and bytes), watermark-based pacing, and drain notifications keep DataChannels responsive under load. Configurable limits:
connection.data_channel_max_sending_messages_per_sec // default: 1000
connection.data_channel_max_sending_bytes_per_sec // default: 64KB
connection.data_channel_min_buffered_amount // default: 64KB
connection.data_channel_max_buffered_amount // default: 1MBThe library characterizes the user's own network (independent of any specific peer) by analyzing local ICE candidates against multiple STUN servers, and emits a networkprofile event once the data is available (and again only if it changes):
peer.on('networkprofile', (p) => {
// p.public_ipv4 — your public IPv4 (from STUN), or null
// p.public_ipv6 — your public IPv6, or null
// p.all_public_ipv4 — array (multiple interfaces)
// p.local_ipv4 — local host IPv4, or null (mDNS-hidden)
// p.symmetric_nat — true = problematic, false = fine, null = couldn't tell
// p.supports_udp — is UDP usable at all
// p.supports_tcp
// p.needs_relay — derived hint: a TURN relay is likely required
});symmetric_nat is the key signal: a symmetric NAT maps the same local socket to a different external address per destination, so direct P2P generally fails and a TURN relay is needed. It's detected by comparing the reflexive mappings reported by several STUN servers — a heuristic hint, not a guarantee (STUN-based NAT typing is inherently unreliable; treat null as "unknown" rather than "fine"). Useful for deciding whether to allocate TURN up front, warning the user, or choosing iceTransportPolicy: 'relay'.
Note: there is no remote NAT type — it can't be measured from your side. Once connected,
connectioninfo.remote.candidateTypetells you how the path to the peer was actually established (host/srflx/relay), which is the operative fact.
Full connection telemetry available via peer.getConnectionInfo():
var info = peer.getConnectionInfo();
// info.type — 'direct-udp', 'direct-tcp', 'relayed', 'unknown'
// info.rtt — round-trip time (ms)
// info.bandwidth_outgoing — estimated outgoing bandwidth (bits/sec)
// info.local.ip — local IP address
// info.local.port — local port
// info.local.protocol — 'udp' or 'tcp'
// info.local.relay — true if using TURN
// info.local.candidateType — 'host', 'srflx', 'prflx', 'relay'
// info.remote.* — same fields for remote side
// For network-level characterization (NAT, public IP, UDP support),
// listen to the `networkprofile` event — see "Network Profiling" above.Per-stream stats are computed automatically from getStats() — no manual polling or delta computation needed:
var streams = peer.getStreams();
// streams['camera'].sending.video.bitrate — bits/sec
// streams['camera'].sending.video.fps — frames per second
// streams['camera'].sending.video.width — frame width
// streams['camera'].sending.video.height — frame height
// streams['camera'].sending.video.codec — e.g. 'video/VP8'
// streams['camera'].sending.video.active — true if actively sending
// Receiving side includes quality metrics:
// streams['camera'].receiving.video.packetLoss — percentage (0–100)
// streams['camera'].receiving.video.jitter — seconds
// streams['camera'].receiving.audio.bitrate — bits/sec
// Use the streamstats event for reactive updates:
peer.on('streamstats', (tagId, direction, stats) => {
updateQualityUI(tagId, stats.video.bitrate, stats.video.packetLoss);
});var peer = new StableWebRTC(options);| Option | Type | Default | Description |
|---|---|---|---|
iceServers |
Array | Google + Twilio STUN | ICE server configuration |
wrtc |
Object | — | Node.js only: WebRTC bindings (@roamhq/wrtc) |
exclude_host_candidates |
Boolean | false |
If true, host candidates aren't sent (privacy; breaks same-LAN direct paths) |
max_signal_chunk_size |
Number | 1024 |
Max bytes per signaling message before chunking (both pipes) |
gatheringTimeout |
Number | — | ms before a stuck ICE-gathering triggers a retry |
gatheringMaxRetries |
Number | — | Max gathering-stuck retries before giving up |
certificates |
Array | shared ECDSA P-256 | Custom DTLS certificate(s) |
iceCandidatePoolSize |
Number | — | Pre-gathered candidate pool size |
portRange |
Object | — | Restrict local port range (where supported) |
bundlePolicy |
String | 'balanced' |
BUNDLE policy ('balanced', 'max-bundle', 'max-compat') |
rtcpMuxPolicy |
String | 'require' |
RTCP mux policy |
| Property | Type | Description |
|---|---|---|
peer.connected |
Boolean | true when DataChannel is open and ready |
peer.connection |
Object | Internal connection state (for advanced inspection) |
| Method | Description |
|---|---|
peer.signal(data) |
Feed signaling data received from the remote peer |
peer.send(data) |
Send data over DataChannel. Accepts String, Uint8Array, or ArrayBuffer |
peer.write(data) |
Alias of send |
peer.stream(tagId, options) |
Add/replace/remove a tagged media stream. options: { video_track, audio_track } |
peer.addStream(stream, options?) |
Add a MediaStream (auto-extracts tracks) |
peer.removeStream(stream) |
Remove a MediaStream |
peer.addTrack(track, stream, options?) |
Add a single MediaStreamTrack associated with a stream |
peer.removeTrack(track, stream) |
Remove a single track |
peer.restartIce() |
Manually trigger an ICE restart |
peer.setConfiguration(config) |
Update the underlying RTCPeerConnection configuration |
peer.getConnectionInfo() |
Returns connection telemetry (type, RTT, bandwidth, endpoints) |
peer.getStreams(direction?) |
Returns all streams with stats. Optional filter: 'sending' or 'receiving' |
peer.on(event, fn) |
Subscribe to an event |
peer.off(event, fn) |
Unsubscribe from an event (same reference required) |
peer.close() / peer.destroy() |
Close and clean up the connection |
| Event | Payload | Description |
|---|---|---|
signal |
data (Uint8Array) |
Signaling data to relay to the remote peer |
connect |
— | DataChannel is open, connection is ready |
data |
data (Uint8Array) |
Incoming DataChannel message |
stream |
mediaStream, info |
Remote media received. info: { tag_id, video_track, audio_track, video_mid, audio_mid } |
statechange |
snapshot |
Any connection state changed. Snapshot includes negotiation_state, signaling_state, data_channel_state, ice_connection_state, connection_state, ice_gathering_state, sctp_state, sctp_dtls_state, connection_type, rtt, bandwidth_outgoing, need_ice_restart, ice_restart_count, epoch |
connectioninfo |
info |
Connection type or candidate pair changed. Same format as getConnectionInfo() |
networkprofile |
profile |
Local network characterized. { public_ipv4, public_ipv6, all_public_ipv4, all_public_ipv6, local_ipv4, symmetric_nat, supports_udp, supports_tcp, needs_relay } |
streamstats |
tagId, direction, stats |
Per-stream stats updated. direction: 'sending' or 'receiving'. stats: { video: { active, width, height, fps, codec, bitrate, packetLoss, jitter }, audio: { active, codec, bitrate, packetLoss, jitter } } |
disconnect |
{ reason, restartCount } |
ICE connection lost. reason: 'disconnected' or 'failed'. Automatic ICE restart begins. |
reconnect |
— | ICE connection recovered after a disconnect |
fingerprints |
localFP, remoteFP |
DTLS fingerprints available for identity verification |
close |
— | Connection closed |
error |
err |
Error occurred |
log |
message |
Internal diagnostic message |
var peer = new StableWebRTC();
// Signaling — relay via your server
peer.on('signal', data => ws.send(data));
ws.onmessage = msg => peer.signal(msg.data);
// Send camera
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => peer.addStream(stream));
// Receive remote video
peer.on('stream', (mediaStream, info) => {
document.querySelector('video').srcObject = mediaStream;
});// Camera — tagged as 'camera'
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
peer.stream('camera', {
video_track: stream.getVideoTracks()[0],
audio_track: stream.getAudioTracks()[0]
});
});
// Screen share — tagged as 'screen'
navigator.mediaDevices.getDisplayMedia({ video: true })
.then(stream => {
peer.stream('screen', { video_track: stream.getVideoTracks()[0] });
});
// Stop screen share
peer.stream('screen', { video_track: null });
// On the receiving side:
peer.on('stream', (mediaStream, info) => {
if (info.tag_id === 'camera') {
document.getElementById('camera-video').srcObject = mediaStream;
} else if (info.tag_id === 'screen') {
document.getElementById('screen-video').srcObject = mediaStream;
}
});var peer = new StableWebRTC();
// Connection lifecycle — no polling needed
peer.on('connect', () => {
showStatus('connected');
});
peer.on('disconnect', (info) => {
showStatus('reconnecting (' + info.reason + ')...');
});
peer.on('reconnect', () => {
showStatus('connected');
});
peer.on('close', () => {
showStatus('disconnected');
});
// Connection quality — reactive updates
peer.on('connectioninfo', (info) => {
showConnectionType(info.type); // 'direct-udp', 'relayed', etc.
showLatency(info.rtt); // 2.1 ms
});
// Per-stream quality — reactive updates
peer.on('streamstats', (tagId, direction, stats) => {
if (direction === 'receiving') {
showBitrate(tagId, stats.video.bitrate);
showPacketLoss(tagId, stats.video.packetLoss);
if (stats.video.packetLoss > 5) {
showQualityWarning(tagId);
}
}
});
// State machine visibility (for debugging)
peer.on('statechange', (snap) => {
console.log('negotiation:', snap.negotiation_state,
'ice:', snap.ice_connection_state,
'dc:', snap.data_channel_state);
});function setupPeer(peer) {
var onStream = (ms, info) => { /* ... */ };
var onStats = (tag, dir, stats) => { /* ... */ };
peer.on('stream', onStream);
peer.on('streamstats', onStats);
// Cleanup — removes the specific listener
return function cleanup() {
peer.off('stream', onStream);
peer.off('streamstats', onStats);
peer.close();
};
}var peers = {};
function connectTo(remoteId) {
var peer = new StableWebRTC();
peers[remoteId] = peer;
peer.on('signal', data => {
server.send({ to: remoteId, signal: data });
});
peer.on('connect', () => {
peer.send('Hello from ' + myId);
});
peer.on('data', msg => {
console.log(remoteId + ' says:', msg.toString());
});
return peer;
}
// When receiving signaling from the server:
server.on('signal', (fromId, data) => {
if (!peers[fromId]) connectTo(fromId);
peers[fromId].signal(data);
});Every WebRTC connection creates a short-lived DTLS certificate. The fingerprints event exposes both peers' certificate fingerprints, enabling end-to-end identity verification — even if the signaling server is compromised.
peer.on('fingerprints', (localFP, remoteFP) => {
// Build a transcript binding this session to both fingerprints
var transcript = makeTranscriptHash(localFP, remoteFP);
// Sign with your application's identity key
var signature = sign(transcript, myPrivateKey);
// Send proof to the remote peer via your signaling channel
sendProof(signature);
});
// When receiving proof from the remote peer:
onReceiveProof((signature, senderPublicKey) => {
// Rebuild the transcript from the remote peer's perspective (reversed)
var transcript = makeTranscriptHash(myRemoteFP, myLocalFP);
if (!verify(transcript, signature, senderPublicKey)) {
throw new Error('MITM detected — fingerprint mismatch');
}
// Connection is cryptographically verified
markAsVerified();
});This is optional — connections work without it. Recommended for messaging, payments, healthcare, and enterprise applications.
"Who is the initiator?" You don't choose. Roles are assigned automatically via nonce comparison at startup.
"What if both peers add video at the same time?" Glare is resolved deterministically. One peer yields (rolls back), the other's offer goes through. The yielding peer re-sends its tracks in the next negotiation cycle. No deadlocks, no dropped tracks.
"What signaling transport should I use?" Anything. WebSocket, HTTP long-polling, MQTT, Server-Sent Events, UDP, even SMS. The library handles reordering, deduplication, and compression. Signaling messages are binary Uint8Arrays — relay them as-is.
"How do I know the connection quality?"
Use the connectioninfo event for connection-level info (type, RTT, bandwidth) and streamstats for per-stream metrics (bitrate, packet loss, jitter). No polling needed — both are event-driven.
"How do I show a reconnecting UI?"
Listen to disconnect and reconnect events. The library handles ICE restart automatically — you just update the UI.
"Can I use this with an SFU?" The library is designed for peer-to-peer connections. For SFU integration (Janus, mediasoup, etc.), you'd use the signaling layer directly but not the media negotiation, since the SFU controls the offer/answer flow.
"What about Firefox / Safari?" The library targets Unified Plan (the standard). Chrome, Firefox, and Safari all support it. Edge cases around rollback behavior differ slightly between browsers — the library accounts for this.
Please ⭐ star the repo to follow progress!
Stable-WebRTC is an evenings-and-weekends project. Support development via GitHub Sponsors or simply share the project.
Apache License 2.0
Copyright © 2025 colocohen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.