You've seen the WebRTC demo. Two browsers, a few lines of code, and suddenly there's a video call running peer-to-peer. No server in the middle, no third-party SDK, just the browser's built-in API. Magic.
Then you try to build an actual product with it.
The camera toggle breaks the call. Adding screen share crashes the negotiation. One user switches from WiFi to cellular and disappears. The DataChannel fills up and the app freezes. You spend three days debugging an issue that only happens when both peers add a track at the same time. You Google "WebRTC glare" and find a 40-line code snippet from MDN that still doesn't cover all edge cases.
This is the WebRTC experience for virtually every developer who's tried to build something real with it. The demos work. The products don't. And the gap between "demo" and "product" is enormous.
Why WebRTC Breaks in the Real World
The browser's RTCPeerConnection API is a low-level protocol binding. It gives you access to every knob and lever of the WebRTC protocol — but it also expects you to coordinate them all correctly. And the coordination is where everything goes wrong.
Glare: The Bug Nobody Warns You About
This is the most common and least understood WebRTC failure. Here's what happens:
Both peers want to renegotiate at the same time. Maybe one user turns on their camera while the other starts screen sharing. Both sides call createOffer() and send it. Now each peer receives an offer while it's already in the "have-local-offer" state.
The WebRTC spec calls this glare. The native API doesn't handle it. It just throws an error or silently corrupts the state. The MDN documentation has a "perfect negotiation" pattern that tries to solve this — but it's 40 lines of tricky code, it doesn't handle all edge cases, and it behaves differently across browsers.
In a two-person call, glare happens every time both users change their media simultaneously. In a group call, it happens constantly.
Signals That Arrive Out of Order
The native API assumes that signaling messages — offers, answers, ICE candidates — arrive in exactly the order they were sent. If an ICE candidate arrives before the offer it belongs to, addIceCandidate() silently fails. The connection never establishes, and there's no error message telling you why.
On a perfect WebSocket connection, messages arrive in order. But in the real world, signaling might go through HTTP, through a message queue, through a serverless function, or through any transport that doesn't guarantee ordering. One message taking 10ms longer than another is enough to break everything.
Renegotiation Storms
User turns on camera → negotiation starts. User immediately toggles mic → another negotiation needed. The first negotiation is still in flight. Now the SDP state is confused — the second negotiation is based on a description that hasn't been applied yet.
This happens every time users quickly toggle media, and it's especially bad during the first few seconds of a call when everyone is adjusting their settings. The result: corrupted state, dropped tracks, or a call that just stops working.
The Connection Dropped — Now What?
User walks from one room to another. WiFi signal drops for two seconds, reconnects. In a normal app, this would be seamless. In WebRTC, the ICE candidates expire, the connection state goes to "disconnected" or "failed", and... now what?
The native API gives you iceConnectionState — a string that changes. It doesn't give you disconnect or reconnect events. It doesn't automatically restart ICE. It doesn't tell you whether the connection is recoverable or dead. Building a "reconnecting..." UI requires polling the state, debouncing transitions, and guessing.
SDP: 4KB of Text for a Simple Connection
Every WebRTC offer and answer includes a Session Description Protocol (SDP) blob. For a simple DataChannel-only connection, this is 2-3KB of text. Add video and it's 4KB+. Add simulcast and you're looking at massive text blocks that need to be sent through your signaling server.
This isn't just wasted bandwidth — it's a real problem for transport-constrained signaling. If you're using MQTT, SMS, or any size-limited transport, SDP becomes a bottleneck.
No Telemetry Without Pain
Want to show the user their connection quality? Bitrate? Packet loss? Resolution? The native API gives you getStats() — a function that returns 30+ different report types. You need to poll it manually, iterate through the reports, match them to the correct stream by MID, compute deltas between polls, and correlate inbound and outbound reports.
Every app with a quality indicator reimplements this logic. Every implementation has bugs.
stable-webrtc: All of This, Handled
stable-webrtc is a library that wraps RTCPeerConnection and handles all of the above internally. The API is simple — the hard problems happen behind the scenes.
npm install stable-webrtc
The Simplest WebRTC Connection
var peer1 = new StableWebRTC();
var peer2 = new StableWebRTC();
// Relay signaling (in production: WebSocket, HTTP, MQTT, anything)
peer1.on('signal', data => peer2.signal(data));
peer2.on('signal', data => peer1.signal(data));
// Connected
peer1.on('connect', () => peer1.send('Hello from peer1!'));
peer2.on('data', msg => console.log('Received:', msg.toString()));
No role assignment. No ICE configuration. No state machine management. Just create two peers, connect their signaling, and it works.
Notice what's missing: there's no "who is the initiator?" decision. Both peers embed a random nonce in their signaling messages. The peer with the higher nonce becomes the initiator automatically. Roles are resolved before any SDP is generated — eliminating an entire class of bugs at the source.
A Video Call That Actually Works
var peer = new StableWebRTC();
// Signaling
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;
});
That's a working video call. But the real test isn't whether it connects — it's what happens next:
User toggles camera off and on? Handled. The library serializes all negotiations through a 6-state machine. Only one negotiation happens at a time. If a new track change arrives while one is in flight, it queues and coalesces.
Both users add a track simultaneously? Handled. Glare is resolved via epoch-rotating politeness — one side always yields, and who yields alternates to prevent starvation. No deadlocks, no dropped tracks.
User switches WiFi to cellular? Handled. The library monitors connection state and automatically triggers ICE restart with configurable timeouts and backoff.
Screen share added on top of camera? Handled. The tagged streams API keeps everything organized:
// 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
navigator.mediaDevices.getDisplayMedia({ video: true })
.then(stream => {
peer.stream('screen', { video_track: stream.getVideoTracks()[0] });
});
// Stop screen share — just null the track
peer.stream('screen', { video_track: null });
// On the receiving side, you know what each stream is
peer.on('stream', (mediaStream, info) => {
if (info.tag_id === 'camera') {
document.getElementById('camera').srcObject = mediaStream;
} else if (info.tag_id === 'screen') {
document.getElementById('screen').srcObject = mediaStream;
}
});
No transceiver management, no m-line ordering bugs, no SDP manipulation. Tag your streams with a name, and the library handles the rest.
Connection Status Without Polling
Building a "reconnecting..." indicator with the native API requires polling iceConnectionState, debouncing rapid transitions, and writing custom logic. With stable-webrtc, it's events:
peer.on('connect', () => {
showStatus('Connected');
});
peer.on('disconnect', (info) => {
showStatus('Reconnecting (' + info.reason + ')...');
// ICE restart happens automatically — nothing to do
});
peer.on('reconnect', () => {
showStatus('Connected');
});
peer.on('close', () => {
showStatus('Disconnected');
});
When the connection drops, the library starts an automatic ICE restart sequence: wait 3 seconds (configurable) in case it recovers naturally, then restart ICE, up to 5 retries with backoff. If it recovers, you get a reconnect event. If it doesn't, you get close. Your code just updates the UI.
Per-Stream Quality Metrics — No getStats() Wrestling
Showing bitrate, packet loss, or resolution per stream normally requires polling getStats(), parsing 30+ report types, matching by MID, and computing deltas. With stable-webrtc:
peer.on('streamstats', (tagId, direction, stats) => {
if (direction === 'receiving') {
showBitrate(tagId, stats.video.bitrate);
showFPS(tagId, stats.video.fps);
showResolution(tagId, stats.video.width + 'x' + stats.video.height);
if (stats.video.packetLoss > 5) {
showQualityWarning(tagId);
}
}
});
Each streamstats event gives you { bitrate, fps, width, height, codec, packetLoss, jitter } for both video and audio, per tagged stream. The library handles the polling, the delta computation, and the report-to-stream matching internally.
Want to check the connection type and latency?
peer.on('connectioninfo', (info) => {
console.log(info.type); // 'direct-udp', 'direct-tcp', 'relayed'
console.log(info.rtt); // 12.3 (milliseconds)
});
SDP Compression: 3KB → 50 Bytes
This is a detail that most developers don't think about, but it matters. Every time you renegotiate (add a track, switch camera, start screen share), a new SDP offer is generated and sent through your signaling server. A typical offer is 3-4KB of text.
stable-webrtc compresses every signaling message using four competing strategies — compact binary encoding, diff against previous SDP, deflate compression, and diff+deflate. The smallest payload wins automatically.
The result: a typical renegotiation (adding a video track) goes from 3KB to about 50-100 bytes. That's small enough for a single UDP packet, an MQTT message, or even an SMS.
And once the DataChannel is open, signaling messages automatically route through the peer-to-peer connection itself — bypassing your server entirely. Renegotiation latency drops to the raw round-trip time between peers.
Mesh Networks: N Peers
Building a group call? Each participant maintains a connection to every other participant:
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('stream', (mediaStream, info) => {
addVideoElement(remoteId, mediaStream);
});
return peer;
}
// When signaling arrives from the server
server.on('signal', (fromId, data) => {
if (!peers[fromId]) connectTo(fromId);
peers[fromId].signal(data);
});
Each connection independently handles its own glare resolution, ICE management, and media negotiation. No centralized coordinator needed.
Works in Node.js Too
stable-webrtc isn't browser-only. In Node.js, pass a WebRTC binding:
var wrtc = require('@roamhq/wrtc');
var peer = new StableWebRTC({ wrtc: wrtc });
Same API, same behavior. This is useful for building server-side recording, media processing pipelines, testing infrastructure, or any scenario where you need a WebRTC peer that isn't a browser.
Security: Verifying Who You're Talking To
Every WebRTC connection creates a short-lived DTLS certificate. Normally, you trust that your signaling server hasn't been compromised. But for sensitive applications — messaging, payments, healthcare — you can verify the peer's identity cryptographically:
peer.on('fingerprints', (localFP, remoteFP) => {
// Both peers see both fingerprints
// You can sign them with your app's identity key
// and verify the other side did the same
// → proof that no MITM tampered with the connection
});
This is optional — connections work without it. But it's there when you need it.
What's Actually Inside
Under the simple API, here's what stable-webrtc is managing for you:
A 6-state negotiation machine that serializes all offer/answer exchanges, preventing concurrent negotiations from corrupting SDP state.
Epoch-rotating glare resolution where which peer yields alternates with each successful negotiation, preventing both starvation and deadlocks.
ICE candidate queueing that holds candidates per-ufrag until the remote description is applied, then drains them in the right order.
Transceiver recycling that reuses inactive transceivers instead of stopping them, keeping m-line ordering stable across renegotiations.
DataChannel backpressure with bounded queues, watermark-based pacing, and drain notifications to prevent memory bloat during high-throughput data transfer.
Automatic ICE restart with configurable timeouts, retry limits, and exponential backoff.
SDP compression with four competing strategies and automatic checksum verification.
Connection telemetry computed from getStats() with proper delta calculation, report matching, and per-stream aggregation.
All of this runs internally. You never see it. You just get a connection that works.
Getting Started
npm install stable-webrtc
var peer = new StableWebRTC();
peer.on('signal', data => sendToRemotePeer(data));
peer.on('connect', () => console.log('Connected!'));
peer.on('data', msg => console.log('Received:', msg));
Resources:
The Point
WebRTC is an incredible technology. Peer-to-peer video, audio, and data in the browser, with no plugins and no intermediary servers. But the native API was designed for protocol implementers, not application developers. The gap between "it connects in a demo" and "it works reliably in production" is measured in months of debugging.
That gap doesn't need to exist. The hard problems — glare, renegotiation, signaling order, ICE management, SDP bloat, telemetry — are solvable. They just need to be solved once, in a library, instead of in every application that touches WebRTC.
That's what stable-webrtc does. All the hard parts, handled. All the simple parts, simple.