From a02ec430c498683cfaffc217aaeb09de478244a1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Jul 2026 16:36:23 -0400 Subject: [PATCH 01/10] fix: attempt to fix room memory leak Room was not clearing its 'devicechange' event because a different handler was being registered than was being cleaned up. --- src/room/Room.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index b09f79bb8f..adf7d52f8c 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -192,6 +192,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** used for aborting pending connections to a LiveKit server */ private abortController?: AbortController; + /** used to remove the `devicechange` listener registered in the constructor */ + private deviceChangeCleanupController?: AbortController; + /** future holding client initiated connection attempt */ private connectFuture?: Future; @@ -375,6 +378,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (isWeb()) { const cleanupController = new AbortController(); + this.deviceChangeCleanupController = cleanupController; let onDeviceChange: () => void; if (Room.cleanupRegistry) { @@ -1864,7 +1868,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) window.removeEventListener('beforeunload', this.onPageLeave); window.removeEventListener('pagehide', this.onPageLeave); window.removeEventListener('freeze', this.onPageLeave); - navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange); + this.deviceChangeCleanupController?.abort(); } } finally { this.setAndEmitConnectionState(ConnectionState.Disconnected); From 198a82f13f7437bbedf49248272968a247e3822e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Jul 2026 16:42:52 -0400 Subject: [PATCH 02/10] fix: add missing changeset --- .changeset/full-tires-call.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/full-tires-call.md diff --git a/.changeset/full-tires-call.md b/.changeset/full-tires-call.md new file mode 100644 index 0000000000..e7ef53ed69 --- /dev/null +++ b/.changeset/full-tires-call.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Fix room GC cycle due to not unregistered devicechange event From 2199f77ccdad7169bc8f6af862316d23dc2406c9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Jul 2026 17:34:37 -0400 Subject: [PATCH 03/10] fix: wrap FrameMetadataManager.room in WeakRef to break GC cycle --- src/frameMetadata/FrameMetadataManager.ts | 29 +++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/frameMetadata/FrameMetadataManager.ts b/src/frameMetadata/FrameMetadataManager.ts index c052c32692..415bd05cf4 100644 --- a/src/frameMetadata/FrameMetadataManager.ts +++ b/src/frameMetadata/FrameMetadataManager.ts @@ -37,7 +37,32 @@ export interface FrameMetadataOptions { export class FrameMetadataManager { private worker?: Worker; - private room?: Room; + /** + * Held as a WeakRef to break the reference cycle between Room and this + * manager (`Room.frameMetadataManager` -> FrameMetadataManager -> Room). + * Without this, a Room could not be garbage collected once it constructed + * a FrameMetadataManager. Access via the `room` getter. + */ + private roomRef?: WeakRef; + + /** + * Fallback strong reference for legacy browsers without WeakRef. This + * reintroduces the reference cycle, but such browsers don't support the + * encoded-transform APIs this feature relies on anyway. + */ + private roomStrong?: Room; + + private get room(): Room | undefined { + return this.roomRef ? this.roomRef.deref() : this.roomStrong; + } + + private setRoom(room: Room) { + if (typeof WeakRef !== 'undefined') { + this.roomRef = new WeakRef(room); + } else { + this.roomStrong = room; + } + } private extractors = new Map(); @@ -58,7 +83,7 @@ export class FrameMetadataManager { if (room === this.room) { return; } - this.room = room; + this.setRoom(room); if (this.worker) { this.worker.onmessage = this.onWorkerMessage; From c09f2c2ce3123fc05113183e710ab4b4e5f181ad Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 14 Jul 2026 11:15:17 -0400 Subject: [PATCH 04/10] feat: add weak ref polyfill and port frame metadata manager over to use it --- src/frameMetadata/FrameMetadataManager.ts | 24 ++++------------- src/utils/weak-ref-polyfill.ts | 32 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 src/utils/weak-ref-polyfill.ts diff --git a/src/frameMetadata/FrameMetadataManager.ts b/src/frameMetadata/FrameMetadataManager.ts index 415bd05cf4..56bd9a0948 100644 --- a/src/frameMetadata/FrameMetadataManager.ts +++ b/src/frameMetadata/FrameMetadataManager.ts @@ -5,6 +5,7 @@ import { RoomEvent } from '../room/events'; import { FrameMetadataExtractor } from '../room/track/FrameMetadataExtractor'; import type RemoteTrack from '../room/track/RemoteTrack'; import RemoteVideoTrack from '../room/track/RemoteVideoTrack'; +import { WeakRefPolyfill } from '../utils/weak-ref-polyfill'; import type { PTDecodeMessage, PTUpdateTrackIdMessage, PTWorkerMessage } from './types'; import { isFrameMetadataSupported, shouldUseFrameMetadataScriptTransform } from './utils'; @@ -38,30 +39,15 @@ export class FrameMetadataManager { private worker?: Worker; /** - * Held as a WeakRef to break the reference cycle between Room and this + * Held as a weak reference to break the reference cycle between Room and this * manager (`Room.frameMetadataManager` -> FrameMetadataManager -> Room). * Without this, a Room could not be garbage collected once it constructed * a FrameMetadataManager. Access via the `room` getter. */ - private roomRef?: WeakRef; - - /** - * Fallback strong reference for legacy browsers without WeakRef. This - * reintroduces the reference cycle, but such browsers don't support the - * encoded-transform APIs this feature relies on anyway. - */ - private roomStrong?: Room; + private roomRef?: WeakRefPolyfill; private get room(): Room | undefined { - return this.roomRef ? this.roomRef.deref() : this.roomStrong; - } - - private setRoom(room: Room) { - if (typeof WeakRef !== 'undefined') { - this.roomRef = new WeakRef(room); - } else { - this.roomStrong = room; - } + return this.roomRef?.deref(); } private extractors = new Map(); @@ -83,7 +69,7 @@ export class FrameMetadataManager { if (room === this.room) { return; } - this.setRoom(room); + this.roomRef = new WeakRefPolyfill(room); if (this.worker) { this.worker.onmessage = this.onWorkerMessage; diff --git a/src/utils/weak-ref-polyfill.ts b/src/utils/weak-ref-polyfill.ts new file mode 100644 index 0000000000..057987a0b9 --- /dev/null +++ b/src/utils/weak-ref-polyfill.ts @@ -0,0 +1,32 @@ +/** + * A `WeakRef`-like reference that falls back to a strong reference on runtime + * environments that do not implement `WeakRef`. + * + * This is primarily useful for breaking reference cycles (so an object can be + * garbage collected once no longer referenced elsewhere) while remaining safe + * on legacy browsers. On those legacy browsers the fallback reintroduces the + * strong reference — and therefore the cycle — which is an acceptable trade-off + * since they typically lack the modern APIs these cycles arise from anyway. + * + * Mirrors the `WeakRef` API: call {@link deref} to retrieve the referenced + * value, which returns `undefined` once it has been collected. + * + * @link https://developer.mozilla.org/en-US/docs/Web/API/WeakRef + */ +export class WeakRefPolyfill { + private weak?: WeakRef; + + private strong?: T; + + constructor(value: T) { + if (typeof WeakRef !== 'undefined') { + this.weak = new WeakRef(value); + } else { + this.strong = value; + } + } + + deref(): T | undefined { + return this.weak ? this.weak.deref() : this.strong; + } +} From c227fb2c3f588a66487a947ed46ed45b95492d45 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 14 Jul 2026 14:54:21 -0400 Subject: [PATCH 05/10] feat: add weak ref wrapper around room e2ee manager reference --- src/e2ee/E2eeManager.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index b7ae206a8d..ca4a879a12 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -22,6 +22,7 @@ import { isVideoTrack, } from '../room/utils'; import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays'; +import { WeakRefPolyfill } from '../utils/weak-ref-polyfill'; import type { BaseKeyProvider } from './KeyProvider'; import { E2EE_FLAG } from './constants'; import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events'; @@ -72,7 +73,11 @@ export class E2EEManager { protected worker: Worker; - protected room?: Room; + private roomRef?: WeakRefPolyfill; + + protected get room(): Room | undefined { + return this.roomRef?.deref(); + } private encryptionEnabled: boolean; @@ -113,7 +118,7 @@ export class E2EEManager } log.info('setting up e2ee'); if (room !== this.room) { - this.room = room; + this.roomRef = new WeakRefPolyfill(room); this.setupEventListeners(room, this.keyProvider); // this.worker = new Worker(''); const msg: InitMessage = { From 511964aaca9cf4d5f949b0c3e962bd7a212e9bce Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 14 Jul 2026 14:55:04 -0400 Subject: [PATCH 06/10] feat: add visibilitychange event cleanup --- src/room/Room.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index adf7d52f8c..9f15660be2 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1351,7 +1351,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) track.enabled = true; const stream = new MediaStream([track]); dummyAudioEl.srcObject = stream; - document.addEventListener('visibilitychange', () => { + const onVisibilityChange = () => { if (!dummyAudioEl) { return; } @@ -1363,9 +1363,11 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); this.startAudio(); } - }); + }; + document.addEventListener('visibilitychange', onVisibilityChange); document.body.append(dummyAudioEl); this.once(RoomEvent.Disconnected, () => { + document.removeEventListener('visibilitychange', onVisibilityChange); dummyAudioEl?.remove(); dummyAudioEl = null; }); From d2b574d0e23d89c884b97c3cd0f3091e1240801f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 14 Jul 2026 14:55:59 -0400 Subject: [PATCH 07/10] fix: clear media stream track listener --- src/room/track/RemoteTrack.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/room/track/RemoteTrack.ts b/src/room/track/RemoteTrack.ts index 3c378aa1cb..04020bbfdf 100644 --- a/src/room/track/RemoteTrack.ts +++ b/src/room/track/RemoteTrack.ts @@ -10,6 +10,8 @@ export default abstract class RemoteTrack< /** @internal */ receiver: RTCRtpReceiver | undefined; + private mediaStreamAbort?: AbortController; + constructor( mediaTrack: MediaStreamTrack, sid: string, @@ -38,11 +40,16 @@ export default abstract class RemoteTrack< /** @internal */ setMediaStream(stream: MediaStream) { + // Detach the listener bound to any previously set stream so the old + // MediaStream (and this track, captured by the handler closure) can be + // garbage collected when a new stream replaces it. + this.mediaStreamAbort?.abort(); + this.mediaStreamAbort = new AbortController(); // this is needed to determine when the track is finished this.mediaStream = stream; const onRemoveTrack = (event: MediaStreamTrackEvent) => { if (event.track === this._mediaStreamTrack) { - stream.removeEventListener('removetrack', onRemoveTrack); + this.mediaStreamAbort?.abort(); if (this.receiver && 'playoutDelayHint' in this.receiver) { this.receiver.playoutDelayHint = undefined; } @@ -51,7 +58,9 @@ export default abstract class RemoteTrack< this.emit(TrackEvent.Ended, this); } }; - stream.addEventListener('removetrack', onRemoveTrack); + stream.addEventListener('removetrack', onRemoveTrack, { + signal: this.mediaStreamAbort.signal, + }); } start() { From ea194e86366ffb1f92c3325c5c17aa9c5d240b20 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 14 Jul 2026 14:56:35 -0400 Subject: [PATCH 08/10] fix: null out srcObject before moving to module global pool --- src/room/track/Track.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/room/track/Track.ts b/src/room/track/Track.ts index 5a502b4ccc..9bb7fff7dd 100644 --- a/src/room/track/Track.ts +++ b/src/room/track/Track.ts @@ -296,6 +296,10 @@ export abstract class Track< // we only need to re-use a single element let shouldCache = true; element.pause(); + // Sever any lingering MediaStream reference before the element sits in + // the module-global pool, so a pooled element can never retain a stream + // (and its tracks) regardless of how detachTrack left srcObject. + element.srcObject = null; recycledElements.forEach((e) => { if (!e.parentElement) { shouldCache = false; From 350e51f231cdc4386ea104ac64e65b9a32393864 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 13:16:31 -0400 Subject: [PATCH 09/10] fix: remove this.deviceChangeCleanupController?.abort() from disconnect --- src/room/Room.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 9f15660be2..920bff77a3 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -192,8 +192,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** used for aborting pending connections to a LiveKit server */ private abortController?: AbortController; - /** used to remove the `devicechange` listener registered in the constructor */ - private deviceChangeCleanupController?: AbortController; /** future holding client initiated connection attempt */ private connectFuture?: Future; @@ -378,7 +376,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) if (isWeb()) { const cleanupController = new AbortController(); - this.deviceChangeCleanupController = cleanupController; let onDeviceChange: () => void; if (Room.cleanupRegistry) { @@ -1870,7 +1867,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) window.removeEventListener('beforeunload', this.onPageLeave); window.removeEventListener('pagehide', this.onPageLeave); window.removeEventListener('freeze', this.onPageLeave); - this.deviceChangeCleanupController?.abort(); } } finally { this.setAndEmitConnectionState(ConnectionState.Disconnected); From bdc9112fc29f712f954b13404bcc9e19c6f010a3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 13:26:43 -0400 Subject: [PATCH 10/10] fix: address lint error --- src/room/Room.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 920bff77a3..c99e950862 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -192,7 +192,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) /** used for aborting pending connections to a LiveKit server */ private abortController?: AbortController; - /** future holding client initiated connection attempt */ private connectFuture?: Future;