WebRTC will encrypt your media, negotiate codecs, traverse NATs, and adapt to congestion without being asked. It will not deliver a single byte from one browser to another before you have connected them yourself. The specification defines the messages two peers must exchange and then stops, deliberately, leaving the delivery to you.
That omission is the first real work in any WebRTC project. This article covers what has to be carried, what the SDP blobs actually contain, the negotiation pattern that handles both peers offering at once, and the failure modes that only appear on the networks you cannot test from.
Why there is no standard signaling protocol
This is not an oversight, and the reasoning holds up. Every application that would use WebRTC already has a concept of who its users are, which of them are online, what a "room" or "call" means in its domain, and who is permitted to join. A mandated signaling protocol would either duplicate all of that or conflict with it.
So the spec defines the payloads — SDP offers and answers, ICE candidates — and leaves the transport open. A chat application signals over its existing message socket. A telephony product signals over SIP. A prototype signals by copy-pasting JSON between two browser tabs, which works exactly as well.
The practical consequence: WebRTC is never a drop-in. There is always a server you write, and the effort of writing it is a meaningful share of any WebRTC project's timeline.
What has to cross the channel
Three things, and a fourth that is yours alone:
- The offer — an SDP blob from the initiating peer describing media, codecs, and cryptographic parameters.
- The answer — the responding peer's SDP, describing what it accepted from that list.
- ICE candidates — a stream of possible network addresses, flowing in both directions, continuing well after the answer.
- Everything your product needs — identity, room membership, presence, permission, hang-up, mute state. None of this is WebRTC's concern and all of it is required for a usable application.
What is actually inside an offer
SDP is a plaintext, line-oriented format from 1998 that WebRTC adopted rather than inventing something. It repays five minutes of reading, because being able to skim one turns several classes of bug into obvious ones. An abridged offer:
v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
m=audio 9 UDP/TLS/RTP/SAVPF 111 103
c=IN IP4 0.0.0.0
a=ice-ufrag:F7gI
a=ice-pwd:x9cml/YzichV2+XlhiMu8g
a=fingerprint:sha-256 D2:FA:0E:C3:22:59:5E:14:95:69:92:3D:13:B4:84:24:2C
a=setup:actpass
a=mid:0
a=sendrecv
a=rtpmap:111 opus/48000/2
a=rtcp-mux
The lines that carry real meaning:
m=audio 9 ...andc=IN IP4 0.0.0.0— port 9 is the discard port and0.0.0.0is a placeholder. The real addresses arrive as ICE candidates. Seeing these in an offer is normal, not a sign that gathering failed.a=ice-ufrag/a=ice-pwd— the credentials ICE connectivity checks are authenticated with. A stale SDP means mismatched ufrags, and mismatched ufrags mean checks that are silently discarded rather than rejected with an error.a=fingerprint— a hash of the sender's DTLS certificate. This is the line that makes WebRTC's encryption mean anything, and the reason the security section below exists.a=setup:actpass— the DTLS role offer. The answerer picksactiveorpassive. Both sides ending upactiveis a handshake that never completes.a=sendrecv— direction. A track added aftercreateOffer()leaves you withrecvonlyhere, which is the usual cause of "I am definitely sending video and they see nothing."a=group:BUNDLEanda=rtcp-mux— everything shares one transport and one port. This is why a modern WebRTC call needs a single working candidate pair rather than one per stream.
Do not parse or edit SDP by hand unless you have exhausted the alternatives. It is tempting — reordering codec lines to force a preference is the classic case — and it is fragile across browser versions. setCodecPreferences() exists for that specific need; see the codec guide.
The state machine, and what it prevents
pc.signalingState moves through a small set of values, and most negotiation bugs are an operation attempted from the wrong one:
| State | Meaning | Valid next step |
|---|---|---|
stable | No negotiation in progress | Create an offer, or accept a remote one |
have-local-offer | You offered; waiting | Set the remote answer |
have-remote-offer | They offered | Create and set your answer |
closed | Connection ended | Nothing |
The error you will meet is InvalidStateError: Called in wrong state: stable, which almost always means an answer arrived for an offer that was already superseded or never made. It is a symptom of the collision problem below, not an isolated bug.
Glare, and the pattern that solves it properly
Both peers can decide to renegotiate at the same moment — someone enables their camera on each end simultaneously, or a network change fires negotiationneeded on both sides at once. Two offers cross in flight. Both peers are in have-local-offer, neither can accept the other's offer, and the connection wedges.
Ad-hoc fixes for this (random backoff, timestamp comparison, "only the caller may renegotiate") mostly work and fail at the worst times. The specification's answer is perfect negotiation: designate one peer as polite and one as impolite when the session is established — a coin flip, a user-ID comparison, anything both sides agree on. On collision, the polite peer rolls back its own offer and accepts the other; the impolite peer ignores the incoming offer and proceeds with its own. Exactly one side yields, deterministically.
let makingOffer = false;
let ignoreOffer = false;
let isSettingRemoteAnswerPending = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(); // implicit createOffer
signal({ description: pc.localDescription });
} finally {
makingOffer = false;
}
};
pc.onicecandidate = ({ candidate }) => signal({ candidate });
onSignal(async ({ description, candidate }) => {
try {
if (description) {
const readyForOffer =
!makingOffer &&
(pc.signalingState === 'stable' || isSettingRemoteAnswerPending);
const offerCollision = description.type === 'offer' && !readyForOffer;
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return; // impolite peer wins, keeps its offer
isSettingRemoteAnswerPending = description.type === 'answer';
await pc.setRemoteDescription(description); // polite peer rolls back here
isSettingRemoteAnswerPending = false;
if (description.type === 'offer') {
await pc.setLocalDescription(); // implicit createAnswer
signal({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) throw err; // expected while ignoring an offer
}
}
} catch (err) {
console.error(err);
}
});
Two things make this shorter than the hand-rolled versions. setLocalDescription() with no argument creates the right description for the current state, so you never choose between createOffer and createAnswer. And setRemoteDescription() performs an implicit rollback when it needs to, so the polite peer's recovery is a single call rather than a state machine you maintain.
Adopt this from the start. Retrofitting it after shipping a caller/callee model means unpicking assumptions spread across your whole media layer.
Choosing a transport
| Transport | Good for | Watch out for |
|---|---|---|
| WebSocket | The default. Bidirectional, low latency, one connection per client. | Reconnection logic is yours; sticky sessions once you run more than one instance. |
| Server-Sent Events + POST | Firewall-friendly, trivially cacheable infrastructure. | Two channels to keep in sync; connection limits per host in older browsers. |
| HTTP long polling | Works absolutely everywhere. | Latency, and latency during candidate trickling is connection setup time. |
| An existing message bus | You already have auth, presence, and delivery. | Ordering guarantees, and per-message overhead if it was built for chat volumes. |
WebSocket is the right default. The thing to size for is not throughput — signaling traffic is tiny — but concurrent idle connections, since every client holds one open for the duration of a call.
A signaling server, minimally
The whole job, for a single-instance room server, is smaller than people expect:
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomId -> Set<WebSocket>
wss.on('connection', (ws) => {
let room = null;
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
// Authenticate HERE. This is the only place you can.
room = msg.room;
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
// The second peer to arrive is impolite; the first is polite.
ws.send(JSON.stringify({ type: 'joined', polite: rooms.get(room).size === 1 }));
return;
}
// Everything else — descriptions and candidates — is relayed untouched.
for (const peer of rooms.get(room) ?? []) {
if (peer !== ws && peer.readyState === peer.OPEN) peer.send(raw.toString());
}
});
ws.on('close', () => {
rooms.get(room)?.delete(ws);
if (rooms.get(room)?.size === 0) rooms.delete(room);
});
});
Note what the server does not do: it never parses SDP, never inspects candidates, never holds media. It routes opaque messages between authenticated participants. Keeping it that dumb is what lets it stay small as your media features grow.
Scaling past one instance means the room map cannot live in process memory. Either pin a room's participants to one instance with sticky routing, or move the fan-out to Redis pub/sub and let any instance serve any client. The second is more work and the one that survives a deploy.
Signaling security is not optional decoration
WebRTC's media encryption is mandatory and unbreakable in the usual sense — and it rests entirely on the a=fingerprint line arriving unmodified.
That line is a hash of the sender's DTLS certificate. The DTLS handshake proves the peer holds the key matching that hash. If an attacker can rewrite SDP in transit, they substitute their own fingerprint, complete a DTLS handshake with each side separately, and relay decrypted media between them. Every browser indicator says the call is encrypted, because it is — to the attacker.
So an insecure signaling channel voids WebRTC's encryption entirely. Concretely:
- WSS, never WS. Plaintext signaling on a hostile network is the whole attack above, requiring nothing clever.
- Authenticate at connect, then authorise every message. A token checked once at handshake time does not stop an authenticated client from relaying into a room it was never invited to. Verify room membership per message.
- Rate limit. An open signaling endpoint is a cheap amplification and resource-exhaustion target.
- Never put TURN credentials in the client bundle. Issue time-limited credentials from the same authenticated endpoint that authorises the call.
- Validate room IDs. Unvalidated identifiers used as map keys are how a signaling server becomes an unmoderated public relay.
Security best practices covers the rest of the surface.
The bugs this layer produces
| Symptom | Cause |
|---|---|
| Connects on your LAN, fails on restrictive networks | Signaling channel closed after the answer, dropping late-trickling relay candidates |
InvalidStateError: Called in wrong state | Glare — two offers crossed. Use perfect negotiation. |
addIceCandidate rejects | Candidate arrived before the remote description. Buffer, or swallow it. |
| Media flows one way only | Track added after createOffer(); SDP says recvonly |
| Call dies when Wi-Fi switches to cellular | Signaling socket dropped, so ICE restart has no path |
| Works between two tabs, fails between two devices | Not signaling — this is connectivity. Verify STUN/TURN. |
That first row is worth dwelling on, because it is the most expensive bug in this list and the least likely to be caught before release. Closing the socket once the answer arrives feels tidy and is invisible on any network where a host or srflx pair already succeeded. It breaks exactly the users whose only working path was a relay candidate that had not arrived yet — which is to say, the users you cannot reproduce and did not test.
The last row is the useful sanity check in the other direction. If two tabs on one machine connect, your signaling is fundamentally working and the problem has moved to connectivity. Confirming your STUN and TURN servers return the candidates they should — from the network in question — separates the two in about a minute.
Check Your Own Servers
Signaling moves SDP and ICE candidates between peers, but those candidates are only useful if your STUN and TURN servers actually respond. Verify the relay and reflexive candidates your servers produce before you debug the signaling layer.
Open the Free ICE Server Tester →