← Back to Wiki
Development / WebRTC

Three Real LiveKit Gotchas From Building a Production Voice Client

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

Share on X

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

LiveKit's own mute/unmute doesn't just stop transmitting audio — by default it stops the underlying MediaStreamTrack entirely. If you're running your own voice-activity detection (an AnalyserNode reading the published track directly, to drive a "someone's talking" UI indicator or a push-to-talk gate), building that analyser directly on the track LiveKit manages means it goes permanently silent the moment mute closes the gate — and never comes back, even after unmuting, because the track it was reading has been stopped, not just muted.

Fix: build your voice-activity analyser on a MediaStreamTrack.clone() of the published track instead of the track itself. Per the WebRTC spec, a cloned track has an independent enabled/lifecycle state from its source — muting the original through LiveKit's own API doesn't touch the clone, so your analyser keeps working across mute cycles indefinitely. This also avoids triggering a second microphone permission prompt, since you're 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

If a user switches voice channels quickly, an event from the room they just left can still fire and land after they've already joined a new one. Room/connection event listeners registered per-room don't automatically know they've been superseded — a late event from a previous room instance can update UI state as if it were still current, and separately, a "connecting" flag can get stuck true if nothing explicitly 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 called 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 immediately if not. Separately, explicitly reset any "connecting"/ 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 because it only shows up under fast, repeated channel switching — a single deliberate join/leave test won't reproduce it.

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

This is the sharpest one: a native iOS app using LiveKit's Swift SDK could reliably fail to connect to voice, timing out with a generic network/validation error — while the exact same account, on the exact 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 actual source rather than guessing further: LiveKit's Swift SDK opts into Multipath TCP "handover" mode for its signaling WebSocket — an iOS-specific networking feature that lets a connection transparently hand over between Wi-Fi and cellular. On networks/paths where that negotiation doesn't complete cleanly, the connection just hangs until the SDK's own fixed join-response timeout fires. Neither curl nor a browser-based JS client ever exercises this code path at all, which is exactly why every server-side and cross-platform check came back clean — the problem was entirely inside one specific client SDK's own networking layer.

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

The general lesson: when a bug is reproducible on exactly one client platform and nowhere else — not the server, not other clients on the same account — the SDK's own platform-specific networking layer is a real suspect, not just app-level code. Reading the actual SDK source for the platform in question found this in an afternoon; guessing at server-side causes for it would never have gotten there.