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.