WiFi → cellular mid-call: what LiveKit already handles, and the two things that still bite

I keep seeing people wire up their own ICE restart logic on top of LiveKit after a call drops on a network switch. You don’t need to the SDK already does it, and hand-rolling it on top usually makes things worse. Here’s what actually happens and where the real gaps are.

What LiveKit does for you

On a network change it reconnects the signalling WebSocket and initiates an ICE restart itself. Most of the time the user sees nothing, or a second or two of frozen video. Only if that resume path fails does it fall back to a full reconnect. (docs)

So if you’re listening for iceconnectionstatechange and calling your own restart, you’re racing the SDK’s recovery, not helping it.

Bite #1: you’re probably listening for the wrong event

RoomEvent.Reconnecting is deliberately not emitted for resumes that changed in #1012. So if your “reconnecting…” spinner is driven off Reconnecting, it stays invisible during the exact scenario you built it for, because the resume path handles most WiFi→cellular switches.

The event you actually want on livekit-client is RoomEvent.SignalReconnecting, which fires when the signal connection drops and resolves with Reconnected. Per its docstring, if media fails on top of that, you then get Reconnecting as well so the two are a ladder, not alternatives.

Connection quality is the other early signal: a value of Lost surfaces before either Reconnecting or Disconnected and means the peer connection to the SFU has actually dropped.

Bite #2: a full reconnect is a rejoin, and it replays every join event

The docs are explicit that the sequence is identical to everyone leaving the room and coming back:

  1. ParticipantDisconnected for everyone else
  2. LocalTrackUnpublished for any unpublished tracks
  3. Reconnecting
  4. full reconnect
  5. Reconnected
  6. ParticipantConnected for everyone currently in the room
  7. local tracks republished → LocalTrackPublished

Steps 6 and 7 are what break apps. Any state you build in a ParticipantConnected or LocalTrackPublished handler track refs, subscription bookkeeping, analytics counters, agent session state gets built a second time. If those handlers aren’t idempotent you end up with duplicate tiles, double-counted sessions, or two renderers fighting over one track. It looks like a rendering bug and it’s really a reconnect.

Keying that state by participant identity and track SID rather than appending on each event fixes it, and it costs nothing when no reconnect happens.

On TURN

Worth knowing the fallback order, because “it works on my WiFi and dies on corporate” is almost always this: ICE/UDP → TURN/UDP 3478 → ICE/TCP → TURN/TLS. On Cloud all four are provided. Self-hosted, only the first two work out of the box TURN needs its own config and certificate, and TURN/TLS on 443 is what saves you behind firewalls that only allow outbound TLS.

Those secure handshakes are the doozy, when writing the backend for those Turns. Great info, and it sure made me reflect on some of the issues I had bumped into.

As host, and pulling my Guest into OBS studio, who are usually behind a firewall, I saw many challenges. Especially since my own ISP was ATT Wireless. :slight_smile: Yeah, and I pulled that off. Just not 4k.

Nice post!

Thanks! And yeah TURN/TLS on 443 is exactly what rescues the guest-behind-a-firewall case. OBS over AT&T Wireless as the host side is a proper stress test.

resume is the default, full reconnect is the fallback. Looking at attemptReconnect in RTCEngine.ts, it goes full when any of these is true:

  • The peer connection is back in NEW state. That’s what device sleep does lid close, phone locked. The comment in the source calls it out directly: those connections can’t be resumed, so it rebuilds.
  • The server told it to. A LeaveRequest with action RECONNECT forces a full reconnect; action RESUME keeps the fast path. That’s how node migrations and redeploys get handled server-side.
  • resumeConnection is disabled in the server-issued client config for the project.
  • Negotiation threw (NegotiationError).
  • A previous resume attempt failed with anything other than SignalReconnectError that promotes the next attempt to full.

Everything else including the ordinary WiFi→cellular switch, where the transport is still alive takes the resume path.

if the network changed, you resume. If the device slept or the server moved you, you rebuild.

Spot on— pointing out the device sleep check returning to NEW state is huge. Mobile OS power management (especially iOS aggressive socket killing when locked) makes that full rebuild path almost inevitable for phone clients, whereas standard network handoffs stay nicely in the fast-lane resume path.

Appreciate you walking through the RTCEngine.tslogic! Having that mental model of when the server triggers a LeaveRequest vs. client-side transport resumption makes designing idempotent event listeners a lot less like guesswork.

Exactly and that’s the case worth designing for, since it’s the one you can’t prevent. On mobile I treat every foreground as a possible rejoin rather than trying to keep the socket alive: reconcile state on Reconnected instead of accumulating it on ParticipantConnected. Costs nothing when the resume path works, and saves you when it doesn’t.