WebRTC One-Way Audio: How to Diagnose and Fix It
WebRTC one-way audio has two separate cause lists depending on direction. Find the dead leg in 60 seconds with webrtc-internals, then fix it by direction.
WebRTC one-way audio has two separate cause lists depending on direction. Find the dead leg in 60 seconds with webrtc-internals, then fix it by direction.
One-way audio in WebRTC means the call connected, ICE succeeded, and RTP is flowing — in one direction only. Which direction is dead decides everything about the fix: a broken outbound leg (they can't hear you) points at your microphone, your NAT, or outbound UDP; a broken inbound leg (you can't hear them) points at your <audio> element, the browser's autoplay policy, or dropped return traffic. The two lists barely overlap, so guessing wastes hours. This guide shows how to identify the dead leg in about a minute from chrome://webrtc-internals, then works each direction's causes in order of likelihood.
One-way audio is a media-path failure that survives a successful signaling and ICE handshake. Signaling completes, the SDP exchange finishes, ICE nominates a candidate pair, connectionState reads connected — and then SRTP packets travel in one direction and not the other. That split is why the symptom is so persistent: every layer you'd normally check reports success.
The causes group into three families. Capture and playback problems mean one side never puts audio into the pipeline or never plays what arrives — a muted OS input, the wrong device selected, an <audio> element the browser refuses to autoplay. Asymmetric network problems mean the media path exists in one direction only — a firewall permitting outbound UDP while dropping the return flow, a symmetric NAT with no TURN relay to fall back on. Mid-call path loss means audio worked and then stopped, which points at TURN allocation expiry, NAT mapping timeouts, or your own reconnect logic tearing down a peer connection it shouldn't.
If the call never carries audio in either direction and iceConnectionState reaches failed, that's a different problem with a different fix path — see WebRTC ICE connection failed. Everything below assumes ICE connected and exactly one leg is silent.
Ask this before touching a single config file. Support tickets almost always arrive as "the audio doesn't work," and the two directions send you to opposite ends of the stack.
| Symptom | Failing leg | First suspects |
|---|---|---|
| You hear them, they hear nothing | Your browser → far end | Mic muted at the OS, wrong input device, call.mute() still set, outbound UDP blocked, symmetric NAT with no relay |
| They hear you, you hear nothing | Far end → your browser | <audio> element blocked by autoplay policy, suspended AudioContext, wrong output device, return RTP dropped by your firewall |
| Neither side hears anything | Both, or ICE never nominated | Not one-way audio — check iceConnectionState first |
The two useful follow-ups: does it reproduce on a different network (points at network), and does it reproduce for the same user on a different machine (points at capture or playback)? A user whose calls work on home Wi-Fi and fail on the corporate VPN has a network problem no amount of device-picker debugging will find.
Open chrome://webrtc-internals in a second tab before starting the call — the page only records peer connections created while it is open. Start the call, reproduce the silence, then return to the tab and expand your peer connection. Four stats settle the question.
| Stat | Where it lives | What a zero or flat value means |
|---|---|---|
audioLevel | media-source (kind: audio) | Your microphone is capturing silence. The failure is local, before any packet is sent. |
packetsSent | outbound-rtp (kind: audio) | Your browser is not transmitting RTP at all. |
packetsReceived | inbound-rtp (kind: audio) | Nothing is arriving from the far end. |
audioLevel | inbound-rtp (kind: audio) | Packets arrive but decode to silence — the far end is sending you nothing. |
packetsSent and packetsReceived are cumulative counters, so a single reading tells you nothing. Watch the graphs for five seconds, or read the stats twice and subtract. In the webrtc-internals UI the counters have live graphs directly beneath the stats table, which is faster than reading numbers.
The same values are available from your own code, which matters when you need a user on another continent to send you a diagnosis:
const pc = /* your RTCPeerConnection */;
const report = await pc.getStats();
for (const s of report.values()) {
if (s.type === 'media-source' && s.kind === 'audio') {
console.log('mic level', s.audioLevel); // ~0 → your mic is silent
}
if (s.type === 'outbound-rtp' && s.kind === 'audio') {
console.log('sent', s.packetsSent, s.bytesSent); // flat → sending nothing
}
if (s.type === 'inbound-rtp' && s.kind === 'audio') {
console.log('recv', s.packetsReceived, 'level', s.audioLevel);
}
}
audioLevel on media-source and on inbound-rtp are both defined in the W3C WebRTC statistics spec, so this reads the same in Chrome, Edge, and Firefox. Now interpret:
packetsSent climbing, far end silent — the packets leave your machine and die en route. Skip to the network sections.packetsSent climbing — you are faithfully transmitting silence. The bug is in capture, not the network.packetsReceived climbing, inbound audioLevel moving, you hear nothing — audio reaches the decoder and never reaches your speakers. The bug is in playback: element attachment, autoplay, or output device.packetsReceived flat — return traffic is being dropped, or the far end genuinely sends nothing.Those four checks take about a minute and eliminate three-quarters of the cause list.
If you're on the Twilio Voice JavaScript SDK you don't hold the RTCPeerConnection yourself, but the SDK carries one-way-audio detection built in. Every 50 ms an active Call emits a volume event with the local input and remote output levels, each a float from 0.0 to 1.0 mapping the range -100 dB to -30 dB:
call.on('volume', (inputVolume, outputVolume) => {
// inputVolume → what your mic is capturing
// outputVolume → what's arriving from the far end
});
Internally the SDK watches both for a value that repeats ten times in a row — roughly half a second of frozen level — and raises a warning named constant-audio-input-level or constant-audio-output-level. Reading the source of @twilio/voice-sdk (2.18.3, checked August 2026), only one of those two ever reaches your application:
// lib/twilio/call.ts
if (warningName !== 'constant-audio-output-level') {
const emitName = wasCleared ? 'warning-cleared' : 'warning';
this.emit(emitName, warningName, /* … */);
}
The output warning is posted to Voice Insights at info level and then deliberately withheld from the warning event — the surrounding comment attributes this to avoiding false positives until the volume metrics are refactored. The practical consequence is worth knowing before you trust the SDK's warnings as a monitor: call.on('warning') will tell you when your microphone goes dead, and will say nothing at all when the far end's audio goes dead. The input warning is also suppressed while call.isMuted() is true, which is correct but means a stuck mute state produces no signal either.
Cover the gap by watching outputVolume yourself:
let flatSamples = 0;
call.on('volume', (inputVolume, outputVolume) => {
flatSamples = outputVolume === 0 ? flatSamples + 1 : 0;
if (flatSamples === 40) { // 40 × 50 ms ≈ 2 seconds
console.warn('No inbound audio energy — inbound leg is dead');
}
});
For a post-mortem after the fact, call.postFeedback(2, 'one-way-audio') records the symptom against the Call SID, and 'one-way-audio' is one of Twilio's documented feedback issue values — which makes the incidents queryable in Voice Insights instead of anecdotal.
Work these in order once webrtc-internals shows a silent mic or flat packetsSent.
The OS is muted or the wrong input is selected. Obvious, and still the most common single cause — a headset with a hardware mute switch, or Chrome capturing the laptop's built-in mic while the user speaks into a USB headset. Twilio's SDK exposes device.audio.availableInputDevices and device.audio.setInputDevice(id); the equivalent in plain WebRTC is navigator.mediaDevices.enumerateDevices(). Note that device labels are empty until permission has been granted at least once, so a picker built before the first getUserMedia() call shows a list of blank entries.
The track exists but produces nothing. A MediaStreamTrack can be live, unmuted, and enabled while capturing pure silence, which is what a device held exclusively by another application looks like on Windows. When capture fails outright you get an explicit error instead — see NotReadableError: could not start audio source, which is the same underlying conflict caught earlier.
The call is still muted from an earlier state. Check call.isMuted() rather than your own UI flag. A mute toggle that drifts out of sync with the SDK — a React state update lost across a re-render, a mute applied before the call reached open — presents to the user exactly as one-way audio.
The mic permission was granted to a different origin. Permissions are per-origin, and an iframe needs allow="microphone" explicitly. Without it getUserMedia() inside the frame fails or silently yields nothing, depending on browser version.
Inbound RTP arriving while you hear nothing is nearly always a playback bug, and the browser rarely raises an error you'd notice.
The remote <audio> element is blocked by autoplay policy. This is the single biggest cause of "you can't hear them" in production, and it is invisible in the network stats — packets arrive, decode, and go nowhere. Chrome and Safari refuse to start playback without a user gesture on the page, and audioEl.play() returns a promise that rejects with NotAllowedError. Always await it:
const audioEl = document.querySelector('#remote-audio');
audioEl.srcObject = remoteStream;
audioEl.play().catch(err => {
// NotAllowedError → surface a "click to enable audio" control
console.error('playback blocked', err.name);
});
A suspended AudioContext. If you route remote audio through Web Audio for level metering or gain control, the context starts in suspended state on a page with no prior user gesture, and every node downstream stays silent. The fix and the full explanation are in AudioContext was not allowed to start. On Twilio's SDK the related disableAudioContextSounds: true device option only affects the SDK's own ringtones, not call audio, so it isn't a fix for this.
The element is never attached, or attached too early. Setting srcObject before the remote track arrives leaves you with an element bound to an empty stream. Attach inside ontrack, using the stream the event hands you.
The wrong output device. setSinkId() can point playback at a monitor output or a disconnected Bluetooth headset. Users who "fixed it by restarting the browser" usually hit this.
Once ICE nominates a candidate pair, both peers send SRTP to that address and port pair. A network device that permits your outbound UDP while dropping the return flow produces textbook one-way audio, and there are several ways to get there.
Stateful firewalls and asymmetric rules. Your outbound packet opens a pinhole keyed on the full 5-tuple. If return traffic arrives from a different source IP or port than the one your packet targeted, the firewall has no matching state and drops it. This is why an ICE handshake over STUN can succeed while media dies: the STUN binding and the media flow don't always take the same path.
Symmetric NAT with no TURN relay. A symmetric NAT allocates a fresh external port per destination, so the mapping the far end was told about no longer accepts its packets. STUN alone cannot solve this. Media relayed through TURN can, which is why TURN is a requirement rather than a fallback on corporate and mobile networks.
Blocked UDP ranges. Twilio's Voice SDK connectivity requirements (page updated July 2026) call for outbound UDP to 168.86.128.0/18 on destination ports 10,000–60,000, plus TCP 443 for signaling to voice-js.roaming.twilio.com. A number of widely-copied troubleshooting guides still quote 10,000–20,000; an allowlist built from that narrower range blocks two-thirds of the media port space and yields intermittent one-way audio that looks random. The bandwidth targets listed there are worth checking against your monitoring: under 200 ms round-trip, under 30 ms jitter, under 3% packet loss, and 40 kbps each way for Opus.
SIP ALG rewriting SDP. If your WebRTC leg is bridged to a PBX or SIP trunk, a router with SIP ALG ("SIP helper", "VoIP passthrough") enabled will rewrite addresses and ports inside SDP bodies, usually badly. The result is one side sending RTP to an address nobody is listening on. Disable it on the router; there is effectively no case where leaving it on helps a modern deployment. In the same bridged setups, disable direct media / re-INVITEs on the trunk so the media stays anchored at one point instead of being renegotiated to a path neither NAT expects.
A distinct pattern with a distinct cause list: audio is fine for somewhere between two and fifteen minutes, then one direction dies while the call stays up.
The most-viewed Stack Overflow thread on this, Twilio WebRTC TURN relay randomly stops working after a few minutes (3,609 views), documents it precisely: packet captures show relayed traffic stopping between 4 and 11 minutes into the session, and subsequent TURN refresh requests returning 437 Allocation Mismatch — the server no longer knows about the allocation the client is trying to keep alive. A related failure surfaces as a 438 on CreatePermission, which normally means a stale nonce the client should simply retry with, but which here signals an allocation that no longer exists.
The answer on that thread is the part worth internalizing: the trigger was client-side. The bundled signaling library closed the peer connection whenever iceConnectionState changed to disconnected — a state that a few lost packets during ordinary congestion will produce, and which normally recovers on its own. Closing the connection there turns a transient blip into a permanent dead leg. If you have reconnect logic that reacts to disconnected, give it a grace period of several seconds and let ICE recover, or trigger an ICE restart; reserve teardown for failed.
Two other candidates in the same time window: a TURN allocation whose lifetime is not being refreshed (silence on a data-only connection is enough to let it lapse — the classic workaround of sending silent audio exists for exactly this reason), and NAT mapping timeouts on idle UDP flows, which typically fire in the 30-second to 5-minute range on consumer routers.
Run the network test from the failing network. networktest.twilio.com checks the audio path and ICE connectivity against Twilio's edges and names the blocked layer in one pass. Have the affected user run it from the machine and network where calls fail — results from your laptop prove nothing about their office VLAN.
Force media through a relay to isolate NAT. Both device.connect() and call.accept() accept an rtcConfiguration object passed straight through to the RTCPeerConnection:
// Outgoing
const call = await device.connect({
params: { To: '+15551234567' },
rtcConfiguration: { iceTransportPolicy: 'relay' },
});
// Incoming
device.on('incoming', call => {
call.accept({ rtcConfiguration: { iceTransportPolicy: 'relay' } });
});
If audio becomes two-way with iceTransportPolicy: 'relay', the problem is NAT traversal on the direct path. Treat this as a diagnostic rather than a permanent setting — relaying adds a hop of latency and forces every byte through the relay.
Fix the allowlist, then verify it. Apply the IP range and port range above to the corporate firewall, then re-run the network test to confirm rather than assuming the change took effect. Pin an edge in DeviceOptions if your network team needs a narrower destination set than Global Low Latency roaming allows.
Turn on debug logging and Voice Insights. new Device(token, { logLevel: 1 }) prints signaling traffic to the console, which shows the last event before the audio died. In the Console, cross-reference the Call SID in Voice Insights for jitter, packet loss, and the constant-audio-output-level events the SDK withheld from your warning handler.
Is one-way audio the same as an ICE connection failure?
No. ICE failure means no media path was ever established and audio is dead in both directions, usually with iceConnectionState reaching failed. One-way audio means ICE succeeded and a candidate pair was nominated — the connection works, and RTP flows in a single direction. Different symptom, different cause list, different fix.
Can a VPN or corporate proxy cause one-way audio? Yes, and it's a common trigger. A VPN can route your outbound media through a tunnel while return traffic takes the direct path, breaking the firewall's connection state. Split-tunnel configurations are the usual culprit. Test the same call with the VPN disconnected before debugging anything else.
Why does putting the call on hold and resuming fix one-way audio? Hold/resume forces a renegotiation, which rebuilds the media path and re-opens NAT pinholes that had gone stale. It's a symptom, not a fix — it tells you the original path setup was wrong. One documented FreePBX case with this exact signature was resolved by upgrading Asterisk from 21 to 22.
Can packet loss cause one-way audio? Not on its own. Packet loss produces choppy or robotic audio in both directions; Twilio's own threshold for acceptable quality is under 3%. One-way audio is 100% loss in a single direction, which is a blocked or misrouted path rather than a degraded one. If Voice Insights shows moderate loss both ways, you have a quality problem instead.
Does one-way audio happen on mobile WebRTC too?
Yes, with the same directional split plus mobile-specific causes: iOS audio session category conflicts, Android background microphone restrictions, and carrier NAT with aggressive UDP timeouts. The Twilio mobile SDKs need the same media range (168.86.128.0/18, UDP 10,000–60,000) reachable, and signaling on TCP 443 for version 3.x and later.
The sharpest finding on this page is a negative: call.on('warning') fires when your microphone dies and stays silent when the far end's audio does, because the SDK withholds constant-audio-output-level from your handler. You only learn that by reading lib/twilio/call.ts.
Alloqui is a drop-in React dialer on your own Twilio or Plivo keys — we watch outputVolume for the flat-line the SDK suppresses, own the <audio> attachment and autoplay recovery, and keep reading the SDK source so the next gap like this one is ours to find. You keep the carrier account and the call records.