Unreliable Connection when using VPN

Our client recently reported Room connection errors on some Windows machines on Firefox and Edge.
We noticed they are using a VPN (so this issue might be OS independent).
Their browser logged this error:
ConnectionError: could not establish signal connection: Encountered unknown websocket error during connection
I tested this on my PC with another VPN provider and did not get this very error though saw this one:
could not establish pc connection
It is also highly browser and region dependent if this error is thrown with the VPN or not.

I also noticed in the network tab that the browser constantly calls a GET request to the livekit cloud regions before it often fails with the error from above.

If this is not “fixable” then can we somehow tell that the connection failed due to an active VPN connection? In this case we can show a more detailed error to the users so they understand they shall deactivate their VPN if possible.

I wonder if they have something that is blocking websockets in the path

I tested this behaviour with this VPN software:

and on some dedicated endpoints with (again) certain browsers I got this issue.
I am not really sure what to look for to be honest.

If you think a blocked websocket is the problem, then can I somehow catch and log this very specific error? Then I can test if this catch is primarily fired when the VPN is being used.

I am not really familiar with your code or what you are doing but you can catch ConnectionError and inspect its message. If it consistently fails during WebSocket setup, you can surface a user hint about VPN/firewall interference.

@CWilson Hello. I was checking this out again, but unfortunately I wasn’t able to catch such a ConnectionError. The LiveKit JS Client SDK did not throw any error during room.connect. I attached you the internal logs that the livekit-client.js is logging, because I am not sure how to properly catch these errors.

@CWilson I have to ping you again, because I really see no solution to this.

I think a few things are going on here, which together explain why room.connect() never throws:

1. That log line isn’t from your initial connect. Look at the sequence in the screenshot: signal disconnected → resuming signal connection, attempt 0 → websocket closed → Encountered unknown websocket error during connection: [object Event]. By that point room.connect() has already been resolved. The error is being thrown inside the SDK’s automatic reconnect loop (RTCEngine.resumeConnection), which catches it internally, schedules another attempt, and only surfaces something to user code once the reconnect policy gives up — at which point you don’t get a RoomEvent.Disconnectednot a rejected promise. So try/catch around room.connect will never see it.

2. [object Event] is the browser, not the SDK. That string is the SDK formatting the DOM Event that the browser passes to the WebSocket error handler. The HTML spec deliberately forbids browsers from exposing details of WebSocket failures (to avoid cross-origin info leaks), so there is literally no richer object available in any SDK or any browser. The useful signal is the WebSocket close code that fires right after — VPN/proxy interference almost always shows up as 1006 (abnormal closure, no clean close frame) with an empty reason.

3. Where to actually hook in. Two complementary places.

Room events — listen to these instead of relying on a thrown error:

import { Room, RoomEvent, ConnectionState, DisconnectReason } from 'livekit-client';

room
  .on(RoomEvent.ConnectionStateChanged, (s: ConnectionState) => console.log('[lk] state', s))
  .on(RoomEvent.SignalReconnecting, () => console.log('[lk] signal reconnecting'))
  .on(RoomEvent.Reconnecting,       () => console.log('[lk] reconnecting'))
  .on(RoomEvent.Reconnected,        () => console.log('[lk] reconnected'))
  .on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
    // Fires after the reconnect policy gives up.
    // Reason is often undefined for VPN-style transport drops.
    console.log('[lk] disconnected', reason);
  });

Log extension — capture the SDK’s own internal logs, including the close code:

import { setLogLevel, setLogExtension, LogLevel } from 'livekit-client';

setLogLevel(LogLevel.debug); // 'trace' while reproducing
setLogExtension((level, msg, ctx) => {
  // The interesting one for you is "websocket closed" — ctx contains
  // { code, reason, wasClean, state }. code === 1006 with an empty
  // reason is the classic VPN/proxy fingerprint.
  if (msg.includes('websocket closed') || msg.includes('unknown websocket error')) {
    myTelemetry.capture({ level, msg, ctx });
  }
});

4. Independent transport probe. Because the SDK can’t expose more than the browser does, the cleanest way to confirm VPN interference is a tiny side-channel probe that opens a raw WebSocket to the same host (no LiveKit involved) and reports its close event:

ts

function probeWss(url: string) {
  const ws = new WebSocket(url);
  ws.onopen  = () => console.log('[probe] open');
  ws.onerror = (e) => console.log('[probe] error', e);
  ws.onclose = (e) => console.log('[probe] close', {
    code: e.code, reason: e.reason, wasClean: e.wasClean,
  });
}

If the probe also closes with 1006 / no reason on Urban VPN and is clean off it, that’s your confirmation, and the right UX on RoomEvent.Disconnected After a reconnect-loop failure, there is a “we detected an unstable connection — VPNs and corporate proxies can interfere with realtime audio/video” hint.

5. Have you tried chrome://webrtc-internals to dig further? It won’t show the WebSocket signaling failure itself (that pipe is the browser’s WS stack, not the PeerConnection), but it’s very useful for the media side of the same problem: under the active RTCPeerConnection you can watch ICE candidate pairs, see whether the connection is going relay-via-TURN (common when VPNs block UDP), watch DTLS state, and see which selected candidate goes disconnected/failed At the same moment, the signal WS dies. If both transports die together, you’re looking at the VPN tearing down the underlying TCP/UDP routes; if only the WS dies but ICE stays connected, you’re looking at something specifically interfering with TLS/HTTP-upgrade traffic to the signal host.

@CWilson Thank you for the reply and comprehensive insight. This is some good stuff I can test out next time. Also number 5 is new to me.
Thank you very much.