← Back to Wiki
Development / WebRTC

LiveKit WebRTC Gotchas: Muting, Stale Room Events and Device Switching

LiveKit is a genuinely solid open-source WebRTC platform. I use it as the voice and video backend for a self-hosted Discord-style chat app, across web, desktop, Android and iOS clients. These are three real, non-obvious bugs I hit shipping that on every platform. One is a WebRTC-spec subtlety. One is a plain React state race. One only ever showed up natively on iOS, and needed reading the SDK's source to root-cause.

Share on X

1. Voice-activity detection goes silent forever after the first mute/unmute

BE WARNED: LiveKit's own mute and unmute does not just stop transmitting audio. By default it stops the underlying MediaStreamTrack entirely. Say you run your own voice-activity detection. An AnalyserNode reading the published track directly, driving a "someone's talking" indicator or a push-to-talk gate. Build that analyser on the track LiveKit manages and it goes permanently silent the moment mute closes the gate. It never comes back, not even after unmuting, because the track it was reading has been stopped rather than muted.

Fix: build your voice-activity analyser on a MediaStreamTrack.clone() of the published track, not the track itself. Per the WebRTC spec a cloned track has an independent enabled and lifecycle state from its source. Muting the original through LiveKit's API does not touch the clone, so your analyser keeps working across mute cycles indefinitely. It also avoids a second microphone permission prompt, since you are cloning an already-granted track rather than opening a new one.

2. A stale room-event listener can clobber current UI state after fast channel switching

BE WARNED: switch voice channels quickly and an event from the room you just left can still fire after you have joined the new one. Room and connection event listeners registered per-room do not know they have been superseded. A late event from a previous room instance updates UI state as if it were still current. Separately, a "connecting" flag gets stuck true if nothing resets it along every path a room can end up disconnected. A clean leave and a connection drop are different code paths. Both need to clear it.

Fix: give every room instance an identity check. An isCurrent() guard at the top of every listener callback, checking whether the room this event belongs to is still the one the UI thinks is active, and bailing out if not. Separately, reset any connecting or loading state on every path a room can end. A clean disconnect event and an explicit leave action both need the same cleanup, not just one of them. This class of bug is easy to miss in testing. It only shows up under fast, repeated channel switching. A single deliberate join and leave will not reproduce it.

3. Native iOS voice connections hang and time out, while the same account works over Safari

This is the sharpest one. A native iOS app using LiveKit's Swift SDK reliably failed to connect to voice, timing out with a generic network error. The same account, on the same device and network, connected instantly over Safari using the JS SDK. Every server-side check came back clean. The signaling endpoint responded correctly to a direct curl. TLS was fine. There was no stale session state. Disabling HTTP/3 as a test made no difference.

Root cause, found by reading the Swift SDK's source instead of guessing further. LiveKit's Swift SDK opts into Multipath TCP "handover" mode for its signaling WebSocket. That is an iOS networking feature letting a connection hand over between Wi-Fi and cellular. On paths where the negotiation does not complete cleanly, the connection hangs until the SDK's own fixed join-response timeout fires. Neither curl nor a browser-based JS client exercises that code path at all. Which is exactly why every server-side and cross-platform check came back clean. The problem lived entirely inside one client SDK's networking layer.

This matched a known, still-open issue in LiveKit's own tracker. A maintainer had already suspected "multipath handling with certain network providers". A one-line fix had been proposed and discussed, then closed unmerged for lack of a clean reproduction. Confirming it independently here was enough to trust the fix. I applied it via a pinned fork forcing multipathServiceType from .handover to .none, instead of waiting for it to land upstream.

The general lesson. When a bug reproduces on exactly one client platform and nowhere else, not the server and not other clients on the same account, the SDK's own platform-specific networking layer is a real suspect. Not just your app code. Reading the SDK source for that platform found this in an afternoon. Guessing at server-side causes would never have gotten there.