WebRTC errors come in two flavors: exceptions thrown synchronously from an API call, and silent failures
where a state machine simply stops advancing. The first kind at least gives you a name to search for. The
second kind, where connectionState reads failed and nothing was logged, is the one
that eats afternoons.
This is a reference organized by the error you are actually staring at. Each entry gives the symptom you observe, the cause that produces it, and the concrete fix. Jump to the one that matches your console and skip the rest.
ICE connection failed
Symptom. iceConnectionState transitions to failed, or
connectionState reaches failed shortly after. No media, no open data channel. The
offer and answer were exchanged successfully, so signaling is not the problem.
Cause. ICE probed every candidate pair and none of them connected. That happens for three main reasons: a firewall blocking the UDP path between the peers, no usable candidate type was gathered on one side (often no relay when a relay was required), or candidates were gathered locally but never delivered to the remote peer over your signaling channel.
Fix. First confirm candidates are reaching the other side; log every
onicecandidate event and every candidate you apply with addIceCandidate. If both
sides see each other's candidates and pairs still fail, you are missing a relay path. Add a working TURN
server and verify it actually gathers relay candidates. The
debugging toolkit walks through reading the
candidate grid that tells you which pairs failed and why.
getUserMedia rejections
Symptom. navigator.mediaDevices.getUserMedia() rejects before any peer
connection is even created. The name on the rejected promise's error tells you which case you hit.
Cause and fix, by error name:
-
NotAllowedError— the user dismissed or denied the permission prompt, or the page is not in a secure context.getUserMediaonly works over HTTPS or onlocalhost; on plainhttp://the API is undefined or rejects immediately. Serve over HTTPS and handle the denial path in the UI. -
NotFoundError— no device matches the requested kind. You asked forvideo: trueon a machine with no camera. Probe withenumerateDevices()and request only the kinds that exist. -
NotReadableError— the device exists but the OS or another application has it locked. On Windows especially, a camera held by another app cannot be opened twice. Tell the user to close the other app; there is no code-side override. -
OverconstrainedError— your constraints cannot be satisfied. You demanded an exact value the hardware does not support, such as{ width: { exact: 3840 } }on a 720p webcam. The error'sconstraintproperty names the offending constraint. Useidealinstead ofexactso the browser can negotiate down.
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
audio: true,
});
} catch (err) {
switch (err.name) {
case "NotAllowedError": showHint("Permission denied or page is not HTTPS."); break;
case "NotFoundError": showHint("No camera or microphone found."); break;
case "NotReadableError": showHint("Device is in use by another app."); break;
case "OverconstrainedError": showHint("Unsupported constraint: " + err.constraint); break;
default: showHint("Unexpected media error: " + err.name);
}
}
No relay candidates gathered
Symptom. Your STUN candidates (srflx) show up, but no relay candidates ever
appear, and calls fail on restrictive networks that need TURN.
Cause. The TURN allocation failed. The recurring reasons are: the scheme is wrong
(turn: sent where the deployment only listens for TLS on turns:, or the reverse),
the credentials are rejected, or the TURN port is blocked. A turns: URL must use the TLS port,
typically 5349, while plain turn: uses 3478.
Fix. Isolate TURN by forcing relay-only gathering. Set
iceTransportPolicy: "relay" and the connection can only succeed if a relay candidate is
gathered, which turns a silent fallback into a loud failure you can debug.
const pc = new RTCPeerConnection({
iceTransportPolicy: "relay", // discard host/srflx; force TURN
iceServers: [
{
urls: ["turn:turn.example.com:3478", "turns:turn.example.com:5349"],
username: "alice",
credential: "s3cret",
},
],
});
If gathering produces nothing under relay-only, the server, scheme, or credentials are at fault rather than your application. Verify the URL set in isolation with the ICE server tester before changing client code.
InvalidStateError and SDP glare
Symptom. setRemoteDescription or setLocalDescription throws
InvalidStateError, frequently when two peers try to renegotiate at the same time.
Cause. The offer/answer exchange is a strict state machine. Calling
setRemoteDescription(offer) while you have already set a local offer, or applying an answer
when you are not in have-local-offer, violates that machine. When both peers fire an offer at
the same moment, neither can accept the other's. This collision is glare.
Fix. Implement perfect negotiation. Designate one peer as polite and one as impolite. The polite peer rolls back its own offer to accept an incoming one; the impolite peer ignores the colliding offer and keeps its own. This removes the race without locking renegotiation behind your own coordination.
let makingOffer = false;
let ignoreOffer = false;
const polite = /* assign one side true, the other false */;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signal({ description: pc.localDescription });
} finally {
makingOffer = false;
}
};
async function onSignal({ description, candidate }) {
if (description) {
const collision =
description.type === "offer" && (makingOffer || pc.signalingState !== "stable");
ignoreOffer = !polite && collision;
if (ignoreOffer) return; // impolite peer wins, drop their offer
await pc.setRemoteDescription(description); // rolls back automatically if polite + collision
if (description.type === "offer") {
await pc.setLocalDescription();
signal({ description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (e) {
if (!ignoreOffer) throw e; // ignore late candidates for an offer we discarded
}
}
}
Failed to set remote answer sdp: Called in wrong state: stable
Symptom. A DOMException reading
"Failed to set remote answer sdp: Called in wrong state: stable".
Cause. You applied an answer while the connection was already in stable. To
accept an answer the connection must be in have-local-offer. This usually means a duplicate
answer arrived (your signaling delivered it twice), or the offer that should have preceded it was never
applied locally.
Fix. Gate the call on the current signaling state and make answer delivery idempotent:
async function applyAnswer(answer) {
if (pc.signalingState !== "have-local-offer") {
console.warn("Ignoring answer; state is", pc.signalingState);
return; // already stable, or never offered — drop the stray answer
}
await pc.setRemoteDescription(answer);
}
DTLS handshake failures
Symptom. iceConnectionState reaches connected — the network path
works — but connectionState never leaves connecting and eventually goes
failed. No media flows.
Cause. ICE succeeded but the DTLS handshake on top of it did not. Two common triggers: the
certificate fingerprint in the SDP no longer matches the certificate the endpoint actually uses, which
happens when an intermediary or your own code munges the SDP and rewrites or drops the
a=fingerprint line; or a firewall permits the initial STUN connectivity checks but filters the
subsequent DTLS packets, so the handshake starts and then stalls.
Fix. Never edit the a=fingerprint or a=setup lines when
manipulating SDP. If you must munge, leave the security and DTLS attributes byte-for-byte intact. If the
fingerprint is untouched and the handshake still stalls after ICE connects, suspect packet filtering on the
media path and route media through a relay.
Audio or video flowing one direction only
Symptom. One peer sees and hears the other, but not the reverse. The connection is fully
established; stats show bytesReceived climbing on one side and stuck at zero on the other.
Cause. Either the network is asymmetric — a firewall permits media in one direction but
blocks the return path — or the SDP direction attributes are wrong. A media section marked
a=recvonly or a=sendonly instead of a=sendrecv will carry media one
way by design.
Fix. Check the transceiver direction with transceiver.direction and set it to
"sendrecv" if you intended bidirectional media. If the directions are correct and one side
still receives nothing, the path is asymmetric; a relay normalizes both directions through a single server
and usually resolves it.
m-line order mismatch
Symptom. An error complaining that the m-lines in the answer do not match the offer, or "The order of m-lines in answer doesn't match order in offer".
Cause. The media sections (m= lines) in an answer must appear in the same
order as in the offer. This breaks almost exclusively when SDP is edited by hand and a section is
reordered, removed, or added.
Fix. Stop manually reordering m-lines. Let the browser generate descriptions with
createOffer/createAnswer and add or remove media through the transceiver API
(addTransceiver, addTrack) rather than text-editing the SDP. The browser keeps the
ordering consistent across renegotiation automatically.
Quick reference
When you only have the error string in front of you, start here.
| Error message | Most likely cause | First thing to check |
|---|---|---|
| ICE connection failed | No viable candidate pair | Are candidates reaching the peer? Is a relay gathered? |
| NotAllowedError | Permission denied or non-HTTPS page | Secure context (HTTPS / localhost) and the prompt result |
| NotReadableError | Device locked by another app | Close other apps using the camera or mic |
| OverconstrainedError | Constraint hardware cannot meet | The constraint field; switch exact to ideal |
| No relay candidates | TURN scheme, credentials, or port | turn: vs turns:, ports 3478/5349, relay-only test |
| Called in wrong state: stable | Duplicate or stray answer applied | signalingState before setRemoteDescription |
| InvalidStateError on offer | Glare (simultaneous offers) | Perfect-negotiation polite/impolite roles |
| DTLS handshake stalls | Fingerprint mismatch or filtered UDP | Untouched a=fingerprint; try a relay |
| One-way media | Direction attribute or asymmetric NAT | transceiver.direction; force relay |
| m-line order mismatch | Manual SDP editing | Use transceiver API instead of munging |
For the underlying mechanics behind several of these — why pairs fail, why relay is sometimes mandatory — the ICE framework explainer fills in the model, and the debugging toolkit shows how to watch these failures happen live.
Check Your Own Servers
"No relay candidates" and most ICE failures trace back to a STUN or TURN server that is not doing what you think. Point our tester at your server URLs and credentials to confirm which candidate types they gather before you debug the rest of the stack.
Open the Free ICE Server Tester →