Common WebRTC Errors and How to Fix Them

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.

Where common WebRTC errors appear in the connection lifecycle A WebRTC session progresses through getUserMedia, signaling, ICE, the DTLS handshake, and media flow. Each stage has its own characteristic failures: permission errors at getUserMedia, SDP state errors at signaling, ICE failures and missing relay candidates at ICE, handshake failures at DTLS, and one-way media at the media stage. WHERE ERRORS SHOW UP IN THE CONNECTION LIFECYCLE getUserMedia Signaling ICE DTLS Media NotAllowedErrorNotFoundError InvalidStateErrorSDP glare ICE failedno relay candidate handshakefailure one-wayaudio / video The first stage that fails is your real bug — the sections below are ordered the same way.
Figure 1 — Every error in this guide belongs to one connection stage. Identify the stage first; the fix follows.

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. getUserMedia only works over HTTPS or on localhost; on plain http:// 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 for video: true on a machine with no camera. Probe with enumerateDevices() 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's constraint property names the offending constraint. Use ideal instead of exact so 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 →