WebRTC Troubleshooting: ICE, Autoplay and Audio Failures

Your call will not connect, or it connects and stays silent. Find the layer that failed in a few minutes, then apply the matching fix.

Michael Trehan

Founder, Protoface

Published

July 7, 2026

Updated

October 2, 2026

Hands testing a circuit board with multimeter probes under a desk lamp
On this page

WebRTC troubleshooting goes fastest in a fixed order. Check microphone permission and HTTPS first, then whether ICE reaches connected, then whether the browser blocked playback, then the media stats. A call that never connects is a network or TURN problem. A call that connects but stays silent is usually autoplay.

WebRTC not working: where to start

Work from the outside in: permission, session setup, ICE state, playback, then media stats.

  1. Page and permission. getUserMedia() needs a secure context, so the page must be HTTPS or localhost. Confirm the site has microphone permission.

  2. Session setup. In the Network panel, the request that creates the session, and the signaling WebSocket if you run one, must succeed. A 401 or 429 there is not a WebRTC fault.

  3. ICE state. Log pc.iceConnectionState. Stuck on checking or ending in failed means no network path was found.

  4. Playback. If ICE is connected and you hear nothing, look for a rejected play() call. Autoplay policy is the usual cause.

  5. Media stats. If the picture is black or frozen, read the inbound video counters from getStats() or webrtc-internals.

Keep the two channels apart while you debug: signaling rides on HTTPS or a WebSocket, media on the peer connection. The comparison of WebRTC vs WebSocket for realtime AI explains which traffic belongs on which.

WebRTC symptoms and fixes at a glance

Match what the user sees to the one check that confirms the cause, then apply the fix.

Symptom

Likely cause

Confirm with

Fix

Never connects

UDP blocked, no usable TURN relay

ICE goes checking to failed, no relay candidate

TURN over TLS on port 443

Connected, no sound

Autoplay blocked

play() rejects with NotAllowedError

Start or resume playback inside a click

Agent cannot hear the user

Microphone denied or wrong device

Site permission, outbound audio bytes

Grant access, reload, pick the device

Black or frozen video

Track not attached, no keyframe, or packet loss

framesReceived, framesDecoded, freezeCount

Attach the stream, fix the codec, lower bitrate

Drops after a network change

Old candidate pair is dead

ICE goes disconnected, then failed

restartIce() and renegotiate

Ends at a regular time

Idle timeout or duration cap

Session status from your provider's API

Raise the timeout, keep audio flowing

Agent interrupts itself

Speaker audio re-enters the microphone

Problem stops with headphones

Fix WebRTC echo cancellation

Connection never establishes: ICE, STUN and TURN failures

If iceConnectionState never reaches connected, the two sides exchanged descriptions but found no network path. The usual cause is a firewall that blocks UDP plus a TURN relay that is missing, misconfigured or unreachable.

What the ICE states tell you

MDN's iceConnectionState reference defines them. checking means candidate pairs are being tested and none has worked yet. connected and completed mean a usable pair exists. failed means every pair was tried without a match. disconnected is milder: checks are failing for now, and the state may return to connected without your help.

What do host, srflx and relay candidates mean?

They are the addresses a browser gathers and offers to the other side. MDN's RTCIceCandidate type reference lists four types in priority order, and three of them show up when you log local candidates: host is the device's own address, srflx is the public address a NAT assigned, as learned from a STUN server, and relay is an address on a TURN server that forwards the media.

  • Only host candidates. The STUN request got no reply. UDP to the STUN server is blocked, or the server URL is wrong.

  • Host and srflx, but ICE fails. The NAT or firewall refuses the direct path. You need a relay.

  • No relay candidate. The TURN server is unreachable or its credentials are wrong or expired.

pc.addEventListener("iceconnectionstatechange", () => {
  console.log("ice:", pc.iceConnectionState);
});
pc.addEventListener("connectionstatechange", () => {
  console.log("peer:", pc.connectionState);
});
pc.addEventListener("icecandidate", ({ candidate }) => {
  if (!candidate) return console.log("gathering finished");
  console.log("local candidate:", candidate.type, candidate.protocol);
});
pc.addEventListener("icecandidateerror", (e) => {
  console.log("ice server error:", e.url, e.errorCode, e.errorText);
});
pc.addEventListener("iceconnectionstatechange", () => {
  console.log("ice:", pc.iceConnectionState);
});
pc.addEventListener("connectionstatechange", () => {
  console.log("peer:", pc.connectionState);
});
pc.addEventListener("icecandidate", ({ candidate }) => {
  if (!candidate) return console.log("gathering finished");
  console.log("local candidate:", candidate.type, candidate.protocol);
});
pc.addEventListener("icecandidateerror", (e) => {
  console.log("ice server error:", e.url, e.errorCode, e.errorText);
});
pc.addEventListener("iceconnectionstatechange", () => {
  console.log("ice:", pc.iceConnectionState);
});
pc.addEventListener("connectionstatechange", () => {
  console.log("peer:", pc.connectionState);
});
pc.addEventListener("icecandidate", ({ candidate }) => {
  if (!candidate) return console.log("gathering finished");
  console.log("local candidate:", candidate.type, candidate.protocol);
});
pc.addEventListener("icecandidateerror", (e) => {
  console.log("ice server error:", e.url, e.errorCode, e.errorText);
});

The listeners print each state change, every local candidate's type and protocol, and any STUN or TURN server the browser could not use. If a platform SDK hides the peer connection, read the same data from webrtc-internals.

Why does WebRTC fail on Windows 11 or on a corporate network?

Because something between the browser and the media server drops UDP: a company firewall, a VPN client or endpoint security software. Ask the network team to allow the media server's UDP ranges, or use TURN over TLS on port 443.

No audio or autoplay blocked

A connected, silent call usually means the browser refused playback because the user has not interacted with the page. Start the session, or at least the audio, from a click or tap.

MDN's autoplay guide lists when playback is allowed: the media is muted, the user has interacted with the site, the site is on the browser's allowlist, or an autoplay Permissions Policy grants it to an iframe. When none applies, play() returns a promise that rejects with NotAllowedError.

const media = document.querySelector("video");
const unmute = document.querySelector("#start-audio");

async function startPlayback() {
  try {
    await media.play();
    unmute.hidden = true;
  } catch (err) {
    if (err.name === "NotAllowedError") unmute.hidden = false;
    else console.warn("playback failed:", err.name);
  }
}

pc.ontrack = ({ streams }) => {
  if (media.srcObject !== streams[0]) media.srcObject = streams[0];
  startPlayback();
};
unmute.addEventListener("click", startPlayback);
const media = document.querySelector("video");
const unmute = document.querySelector("#start-audio");

async function startPlayback() {
  try {
    await media.play();
    unmute.hidden = true;
  } catch (err) {
    if (err.name === "NotAllowedError") unmute.hidden = false;
    else console.warn("playback failed:", err.name);
  }
}

pc.ontrack = ({ streams }) => {
  if (media.srcObject !== streams[0]) media.srcObject = streams[0];
  startPlayback();
};
unmute.addEventListener("click", startPlayback);
const media = document.querySelector("video");
const unmute = document.querySelector("#start-audio");

async function startPlayback() {
  try {
    await media.play();
    unmute.hidden = true;
  } catch (err) {
    if (err.name === "NotAllowedError") unmute.hidden = false;
    else console.warn("playback failed:", err.name);
  }
}

pc.ontrack = ({ streams }) => {
  if (media.srcObject !== streams[0]) media.srcObject = streams[0];
  startPlayback();
};
unmute.addEventListener("click", startPlayback);

The code attaches the remote stream once, tries to play it, and reveals a button when the browser refuses. The click is the interaction, so the retry succeeds.

  • Web Audio. Chrome's autoplay policy says an AudioContext created before a user gesture starts in the suspended state. Call audioContext.resume() inside the click handler.

  • Iframes. A cross-origin frame needs allow="microphone; autoplay" on the <iframe> tag, or it can neither capture the visitor nor play sound.

  • LiveKit rooms. The LiveKit JavaScript SDK reports blocked audio through RoomEvent.AudioPlaybackStatusChanged and room.canPlaybackAudio. Call room.startAudio() from a click or tap handler.

  • Silence at the source. If inbound audio bytes rise while totalAudioEnergy stays flat, the sender is transmitting silence. Debug the agent's speech output, not the browser.

Test autoplay in a clean profile. Chrome on desktop also allows sound on sites where you have played media before, so your own machine can hide the bug. Reproduce it in a new profile.

Avatar joins but stays silent

With Protoface Realtime on LiveKit, the avatar participant publishes both the audio and the video, driven by the audio your agent produces. If it joins and says nothing, confirm the agent can produce speech and that the avatar joined the same room as the user. If a hosted embed cannot hear the visitor, check the browser's microphone permission for that origin, then reload.

Black or frozen video

A black or frozen frame means the sender stopped, no keyframe was decoded, packets are being lost, or the track is not attached to a visible element. The inbound video counters tell them apart.

What the stats show

Meaning

Fix

bytesReceived flat

No video is arriving

Check the sender, the subscription and the session state

framesReceived rises, framesDecoded is 0

No keyframe yet, or the decoder rejects the codec

Check keyFramesDecoded and the negotiated codec

framesDecoded rises, picture is black

Rendering problem in the page

Check srcObject, element size and whether the element was remounted

freezeCount and packetsLost rise

Loss or not enough bandwidth

Lower resolution or bitrate, move off the congested network

All of these fields are documented in MDN's RTCInboundRtpStreamStats reference. They are running totals, so compare two samples a few seconds apart.

Framework re-renders often cause the rendering case. If React, Svelte or Vue recreates the <video> element, the new element has no stream. Reattach it on mount.

An empty avatar tile during startup is not a fault. A Protoface session moves through queued, starting and running before the first frame, and first_frame_at is set when that frame reaches the room. To measure cold start, compare created_at with first_frame_at from GET /v1/sessions/{id}.

For stuttering motion, read jitter, packetsLost and framesPerSecond on the video stream. Choppy speech shows as a rising concealedSamples count on the audio stream.

Session disconnects randomly

Drops that look random fall into three groups: the network path changed, the signaling socket closed, or a server ended the session on purpose. Drops tied to movement or Wi-Fi are transport. Drops at a regular interval are a timeout.

Network changes and ICE restarts

When a laptop switches from Wi-Fi to a hotspot, the selected candidate pair stops working and ICE reports disconnected. Wait a few seconds, then restart ICE. MDN's restartIce() reference explains that the call fires negotiationneeded and makes the next offer gather fresh candidates on both ends.

let timer;
pc.addEventListener("iceconnectionstatechange", () => {
  clearTimeout(timer);
  if (pc.iceConnectionState === "failed") pc.restartIce();
  if (pc.iceConnectionState === "disconnected") {
    timer = setTimeout(() => pc.restartIce(), 4000);
  }
});
pc.addEventListener("negotiationneeded", async () => {
  await pc.setLocalDescription(await pc.createOffer());
  signaling.send(JSON.stringify({ type: "offer", offer: pc.localDescription }));
});
let timer;
pc.addEventListener("iceconnectionstatechange", () => {
  clearTimeout(timer);
  if (pc.iceConnectionState === "failed") pc.restartIce();
  if (pc.iceConnectionState === "disconnected") {
    timer = setTimeout(() => pc.restartIce(), 4000);
  }
});
pc.addEventListener("negotiationneeded", async () => {
  await pc.setLocalDescription(await pc.createOffer());
  signaling.send(JSON.stringify({ type: "offer", offer: pc.localDescription }));
});
let timer;
pc.addEventListener("iceconnectionstatechange", () => {
  clearTimeout(timer);
  if (pc.iceConnectionState === "failed") pc.restartIce();
  if (pc.iceConnectionState === "disconnected") {
    timer = setTimeout(() => pc.restartIce(), 4000);
  }
});
pc.addEventListener("negotiationneeded", async () => {
  await pc.setLocalDescription(await pc.createOffer());
  signaling.send(JSON.stringify({ type: "offer", offer: pc.localDescription }));
});

The handler restarts at once on failed and after four seconds of disconnected, a delay to tune. signaling is your own WebSocket, and the far side must answer the new offer.

Signaling WebSocket drops

Media can outlive the signaling socket, but you can no longer renegotiate or receive events. Read the code in the socket's close event: 1000 is a normal close, and 1006 means the connection vanished without a close frame, often a proxy cutting an idle connection. Send a heartbeat and reconnect with backoff and a fresh token. A handshake that returns 401 or 403 is an auth problem: an expired token or a wrong key.

The server ended the session

Check the session's status before you blame the network. A Protoface session ends when you close it, after idle_timeout_seconds without inbound audio, or at your plan's duration cap. The idle default is 30 seconds, and the timer follows the audio your agent sends, not when the visitor stops speaking. A tool call that keeps the agent silent for longer than the timeout ends the session.

curl https://api.protoface.com/v1/sessions/sess_... \
  -H "Authorization: Bearer $PROTOFACE_API_KEY"
curl https://api.protoface.com/v1/sessions/sess_... \
  -H "Authorization: Bearer $PROTOFACE_API_KEY"
curl https://api.protoface.com/v1/sessions/sess_... \
  -H "Authorization: Bearer $PROTOFACE_API_KEY"

The response carries the session's status. If a new session will not start, read the error code: concurrent_sessions and session_start_rate_limited both return HTTP 429. On the rate limit, wait for the Retry-After delay when the response carries one. concurrent_sessions means too many sessions are running for your plan, so end one before you retry. Hosted embeds dispatch protoface-avatar:ended with reason and failed, and protoface-avatar:error with a code, so log both.

How to read webrtc-internals and getStats

Open chrome://webrtc-internals in Chrome or edge://webrtc-internals in Edge before you start the call, so the event log and the graphs cover the connection from its first candidate. Firefox has about:webrtc.

  • Never connects. Find iceconnectionstatechange in the event log, then check whether any candidate-pair has state succeeded.

  • Which path is in use. On the nominated pair, follow the local candidate ID to its candidateType. relay means the call runs through TURN.

  • Silence or black video. The inbound-rtp blocks graph bytesReceived, totalAudioEnergy, framesDecoded, freezeCount.

  • The agent hears nothing. In outbound-rtp with kind audio, bytesSent should climb while the user talks.

async function snapshot(pc) {
  const report = await pc.getStats();
  report.forEach((s) => {
    if (s.type === "candidate-pair" && s.nominated && s.state === "succeeded") {
      const local = report.get(s.localCandidateId);
      console.log("path:", local.candidateType, local.protocol,
        "rtt s:", s.currentRoundTripTime);
    }
    if (s.type === "inbound-rtp" && s.kind === "video") {
      console.log("video:", s.framesReceived, s.framesDecoded,
        s.keyFramesDecoded, s.freezeCount, s.packetsLost);
    }
    if (s.type === "inbound-rtp" && s.kind === "audio") {
      console.log("audio:", s.bytesReceived, s.concealedSamples,
        s.totalAudioEnergy);
    }
  });
}
setInterval(() => snapshot(pc), 5000);
async function snapshot(pc) {
  const report = await pc.getStats();
  report.forEach((s) => {
    if (s.type === "candidate-pair" && s.nominated && s.state === "succeeded") {
      const local = report.get(s.localCandidateId);
      console.log("path:", local.candidateType, local.protocol,
        "rtt s:", s.currentRoundTripTime);
    }
    if (s.type === "inbound-rtp" && s.kind === "video") {
      console.log("video:", s.framesReceived, s.framesDecoded,
        s.keyFramesDecoded, s.freezeCount, s.packetsLost);
    }
    if (s.type === "inbound-rtp" && s.kind === "audio") {
      console.log("audio:", s.bytesReceived, s.concealedSamples,
        s.totalAudioEnergy);
    }
  });
}
setInterval(() => snapshot(pc), 5000);
async function snapshot(pc) {
  const report = await pc.getStats();
  report.forEach((s) => {
    if (s.type === "candidate-pair" && s.nominated && s.state === "succeeded") {
      const local = report.get(s.localCandidateId);
      console.log("path:", local.candidateType, local.protocol,
        "rtt s:", s.currentRoundTripTime);
    }
    if (s.type === "inbound-rtp" && s.kind === "video") {
      console.log("video:", s.framesReceived, s.framesDecoded,
        s.keyFramesDecoded, s.freezeCount, s.packetsLost);
    }
    if (s.type === "inbound-rtp" && s.kind === "audio") {
      console.log("audio:", s.bytesReceived, s.concealedSamples,
        s.totalAudioEnergy);
    }
  });
}
setInterval(() => snapshot(pc), 5000);

The function prints the active path and the failure counters every five seconds. Send them to your tracing backend with the session ID. The article on OpenTelemetry for voice and avatar apps shows how to structure those spans.

Testing a network for WebRTC

A working network returns an srflx candidate from your STUN server and a relay candidate from your TURN server.

  1. Open the WebRTC project's Trickle ICE sample on the network you want to test.

  2. Add your STUN server and gather candidates. The server works if you get a candidate of type srflx.

  3. Add your TURN server with its username and password. It works if you get a candidate of type relay.

  4. Repeat with the TURN URL that uses TLS on port 443.

  5. Give the network team the hosts and ports your media provider documents. STUN and TURN default to port 3478 and TURN over TLS to 5349, though many providers also offer it on 443.

To run the relay check in your own app before a session, force relay-only gathering:

async function turnReachable(iceServers) {
  const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" });
  pc.createDataChannel("probe");
  const found = new Promise((resolve) => {
    pc.onicecandidate = ({ candidate }) => {
      if (candidate && candidate.type === "relay") resolve(true);
      if (!candidate) resolve(false);
    };
  });
  await pc.setLocalDescription(await pc.createOffer());
  const ok = await found;
  pc.close();
  return ok;
}
async function turnReachable(iceServers) {
  const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" });
  pc.createDataChannel("probe");
  const found = new Promise((resolve) => {
    pc.onicecandidate = ({ candidate }) => {
      if (candidate && candidate.type === "relay") resolve(true);
      if (!candidate) resolve(false);
    };
  });
  await pc.setLocalDescription(await pc.createOffer());
  const ok = await found;
  pc.close();
  return ok;
}
async function turnReachable(iceServers) {
  const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" });
  pc.createDataChannel("probe");
  const found = new Promise((resolve) => {
    pc.onicecandidate = ({ candidate }) => {
      if (candidate && candidate.type === "relay") resolve(true);
      if (!candidate) resolve(false);
    };
  });
  await pc.setLocalDescription(await pc.createOffer());
  const ok = await found;
  pc.close();
  return ok;
}

With iceTransportPolicy: "relay" the browser gathers only TURN candidates, so the promise resolves true when the relay answers and false when gathering ends without one. Pass the iceServers your calls use, and tell the user when it fails.

Common questions

Why is my WebRTC not working?

Most failures are one of four things: the page lacks microphone permission or HTTPS, a firewall blocks UDP and no TURN relay is reachable, the browser's autoplay policy blocked the audio, or the remote track was never attached to a media element. Check them in that order.

Can Chrome flags disable WebRTC?

No. Chrome on desktop has no built-in flag or setting that switches WebRTC off. Extensions, enterprise policies and VPN or security software can restrict which addresses and ports it uses, which is enough to break calls. Firefox can turn it off with the media.peerconnection.enabled setting in about:config.

How can I check if my WebRTC is leaking?

Connect your VPN, open the Trickle ICE sample and gather candidates with a STUN server. If a srflx candidate shows your ISP's public address instead of the VPN's, WebRTC is exposing it.

What are the downsides of using WebRTC?

You need signaling, STUN and TURN infrastructure, and failures often appear only on other people's networks. Browser rules for autoplay and device permissions add more failure points, and debugging takes tools such as webrtc-internals and getStats.

How do I open webrtc-internals in Chrome and Edge?

Type chrome://webrtc-internals in Chrome's address bar or edge://webrtc-internals in Edge. Open it before the call starts, so the event log and graphs cover the connection from its first candidate.

Why is there no sound in my WebRTC call until I click the page?

The browser's autoplay policy blocks audible playback until the user interacts with the site. Call play() on the media element, or resume the audio context, inside a click or tap handler.

Put a face on the agent you just fixed

Protoface Realtime joins your LiveKit room or Pipecat pipeline as the avatar and publishes ordinary audio and video tracks you can debug with the same tools.

Start free or see Protoface Realtime.

Michael Trehan

Founder, Protoface

Michael is the founder of Protoface. He was previously a software engineer at Radiant Nuclear and worked in investment banking at JP Morgan.

Keep reading