Skip to content

disconnectOnPageLeave: false does not prevent disconnect on browser tab freeze (Page Lifecycle freeze event) #1968

@zodiac141

Description

@zodiac141

Describe the bug

Setting disconnectOnPageLeave: false when creating a Room does not prevent the SDK from calling room.disconnect() when the browser freezes a background tab via the Page Lifecycle API freeze event. The room is torn down silently and the participant is kicked without any disconnect reason being surfaced to the app.

Root Cause

The SDK unconditionally registers room.onPageLeave as a listener on both pagehide/beforeunload AND window 'freeze'. The disconnectOnPageLeave flag correctly gates the pagehide/beforeunload path, but the freeze listener is always attached regardless of this flag.

Inspecting the SDK source (simplified):

// SDK internal setup — always runs regardless of disconnectOnPageLeave
window.addEventListener('freeze', this.onPageLeave);
// Only gated by disconnectOnPageLeave:
if (this.options.disconnectOnPageLeave) {
window.addEventListener('pagehide', this.onPageLeave);
window.addEventListener('beforeunload', this.onPageLeave);
}

So disconnectOnPageLeave: false has no effect on the freeze path.

Why this matters in production

Edge's Sleeping Tabs / Efficiency Mode aggressively freezes background tabs that have no active media session. The key asymmetry in a classroom app:

Teacher tab: publishing audio/video → browser recognises an active media session → tab is never frozen
Student tab (viewer-only, mic/camera off): no active media session → tab is frozen when the student switches to another window
The result: students who switch away from the classroom tab are silently kicked. Teachers are completely immune. This is extremely hard to debug because it looks like a random WebSocket drop with no reason code.

Minimal reproduction

import { Room, RoomEvent } from 'livekit-client';
const room = new Room();
await room.connect(WS_URL, TOKEN, {
disconnectOnPageLeave: false, // <-- should prevent SDK-initiated disconnects on nav/freeze
});
room.on(RoomEvent.Disconnected, (reason) => {
console.log('disconnected:', reason);
// Fires with reason=undefined when Edge freezes the tab
// Expected: should NOT fire because disconnectOnPageLeave is false
});
// Reproduce:
// 1. Join a room from Edge (Sleeping Tabs on by default)
// 2. Switch to a different tab for ~2+ minutes
// 3. Return — room is gone, Disconnected event already fired

Workaround we implemented

We work around it by manually removing the freeze listener after every successful connect. We access room.onPageLeave via a cast because it is not exposed on the public type:

function NoFreezeGuard(): null {
const room = useRoomContext();
useEffect(() => {
const removeFreezeListener = (): void => {
// room.onPageLeave is the bound arrow function the SDK registered.
const handler = (room as any).onPageLeave as EventListener | undefined;
if (handler) window.removeEventListener('freeze', handler);
};
// Run after every RoomEvent.Connected (initial connect + Phase 2 remounts).
room.on(RoomEvent.Connected, removeFreezeListener);
// Also run immediately in case Connected already fired before this effect.
removeFreezeListener();
return () => { room.off(RoomEvent.Connected, removeFreezeListener); };
}, [room]);
return null;
}

This works, but it relies on an internal private property name (onPageLeave) that could break across SDK releases. It should not be necessary.

Expected behaviour

When disconnectOnPageLeave: false is set, the SDK should not call room.disconnect() for any page lifecycle event — including freeze. The intent of the option is clearly "do not disconnect when the user navigates or backgrounds the tab"; the freeze event is squarely within that intent.

Questions for the team

Was the freeze listener intentionally excluded from the disconnectOnPageLeave gate, and if so, why?
Is there a supported API to opt out of the freeze listener without reaching into private properties?
On unfreeze, does the SDK's Phase 1 reconcile/reconnect mechanism attempt to restore the WebSocket if the connection was lost while frozen? In our testing it does not — the connection is already gone because disconnect() was called deliberately before the freeze completed.
Would you consider either:
Gating the freeze listener on disconnectOnPageLeave alongside pagehide/beforeunload
Exposing a separate disconnectOnFreeze option (useful for apps that want different behaviour on tab-freeze vs. full page navigation)
Additional context

A secondary issue compounds this: if Phase 2 (full room remount with a new token) kicks in while the tab is still frozen, setTimeout-based retry delays are throttled to a minimum 1-minute interval by the browser's background timer throttling. We worked around this by resolving delays immediately on visibilitychange → visible, but the root issue (the freeze disconnect) is what we'd like fixed upstream.

Reproduction

Can we connect over meet so that i can show you the issue that is happening.

Logs

This disconnect does not produce server-side logs — the SDK calls room.disconnect()
synchronously in the browser's freeze event handler before any network request
is made. The only evidence is in the browser's DevTools console.

With setLogLevel('debug') enabled, the sequence immediately before disconnect is:

[LK] ℹ room state changed  disconnected
[LK] ℹ RoomEvent.Disconnected reason=undefined

There is no preceding WebSocket error, no close frame, and no onDisconnected
reason code — the connection is terminated client-side by the SDK's own
freeze listener before the server is aware anything happened.

Our production app confirmed the behaviour: students returned to the classroom
tab to find themselves in a disconnected state with no error shown, and the
server-side logs showed zero disconnect events from LiveKit webhooks around
that time — consistent with a client-initiated disconnect that the server
treats as a clean leave.

System Info

OIT LMS — System Info
System
OS	Ubuntu 24.04.2 LTS (Noble Numbat) — Linux 6.17
CPU	4× Intel Xeon Platinum 8581C @ 2.30GHz (x64)
Memory	3.19 GB / 7.75 GB
Environment	Replit container
Shell	Bash 5.2.21
Binaries
Node.js	24.13.0
pnpm	10.26.1
npm	11.6.2
Yarn	1.22.22
Bun	1.3.6
LiveKit Packages
Package	Version
livekit-client	^2.18.7
livekit-server-sdk	^2.15.2
@livekit/components-react	^2.9.20
@livekit/components-styles	^1.2.0
@livekit/krisp-noise-filter	^0.4.3
App Stack
Frontend	React 18 + Vite + Wouter + TanStack Query v5
Backend	Express 5
Database	PostgreSQL + Drizzle ORM
TypeScript	5.9
Package manager	pnpm workspaces 10.26.1

Severity

blocking an upgrade

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions