Skip to content

Attempt to fix room GC cycle resulting in memory leak#2009

Open
1egoman wants to merge 10 commits into
mainfrom
room-memory-leak
Open

Attempt to fix room GC cycle resulting in memory leak#2009
1egoman wants to merge 10 commits into
mainfrom
room-memory-leak

Conversation

@1egoman

@1egoman 1egoman commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #2008

In the linked issue, it was reported that Room was not being garbage collected properly, leading to Room instances piling up in memory when a user joins and leaves a room in rapid succession.

The one issue reported was definitely valid - Room was not clearing its 'devicechange' event because a different handler was being registered than was being cleaned up. So, I adjusted the 'devicechange' unregistration mechanism to instead use the cleanupController added in #1944.

In addition, I found another reference cycle: Room.frameMetadataManager is a FrameMetadataManager, and FrameMetadataManager.room is a Room. To fix this one, I've made some updates to FrameMetadataManager to wrap room in a WeakRef.

With both of these updates, after running 10x connect -> disconnect cycles in a row, there don't look to be any clearly suspicious reference cycles in the heap snapshot:

Screenshot 2026-07-13 at 5 36 32 PM

Room was not clearing its 'devicechange' event because a different
handler was being registered than was being cleaned up.
@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bdc9112

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@1egoman 1egoman changed the title fix: attempt to fix room memory leak Attempt to fix room GC cycle resulting in memory leak Jul 13, 2026
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 101.55 KB (+0.07% 🔺)
dist/livekit-client.umd.js 110.52 KB (+0.14% 🔺)

@1egoman 1egoman marked this pull request as ready for review July 13, 2026 21:38
devin-ai-integration[bot]

This comment was marked as resolved.

@OTaran2107

Copy link
Copy Markdown

@1egoman Thanks for working on this!

We tested this branch locally with our application. The memory leak situation has definitely improved compared to the current release, so this fix is making a noticeable difference. However, we're still seeing retained Room, MediaStream, and related objects after calls, so it doesn't appear to resolve the issue completely in our case.

While investigating, we noticed a few other areas that might be worth checking. We haven't fully verified all of them yet, so please treat these as potential leads rather than confirmed bugs:

  • RemoteTrack.setMediaStream() – if a new MediaStream replaces the previous one, the removetrack event listener may remain attached to the old stream, preventing it from being garbage collected.
  • SignalClient.resetCallbacks() – it looks like not all callback references are cleared during disconnect. If some callbacks still reference Room or participants through closures, they could keep the room alive.
  • E2EE event listeners – listeners registered on E2EEManager may not always be removed during cleanup, potentially retaining Room.
  • visibilitychange listener (iOS audio handling) – this listener may remain registered after disconnect and capture the Room instance.
  • recycledElements audio element pool – if pooled media elements still reference a MediaStream through srcObject, they could retain media resources longer than expected.

Would it be possible to take a look at these areas as well? They seem like plausible candidates based on the heap snapshots we're seeing, although we haven't confirmed them individually yet.

Thanks again for investigating and for the work on this fix.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread src/room/Room.ts
Comment on lines 1866 to 1869
window.removeEventListener('beforeunload', this.onPageLeave);
window.removeEventListener('pagehide', this.onPageLeave);
window.removeEventListener('freeze', this.onPageLeave);
navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Device-change listener is never removed on disconnect for legacy browsers, leaking the Room

The device-change listener is no longer unregistered on disconnect (handleDisconnect at src/room/Room.ts:1865-1869) after the explicit removeEventListener call was deleted, so on legacy browsers (without WeakRef/FinalizationRegistry) the handler permanently retains the Room object.

Impact: On legacy browsers, a disconnected Room can never be garbage-collected and continues to react to device-change events.

Legacy-path reference chain keeps Room alive after disconnect

In the constructor (src/room/Room.ts:395-399), when Room.cleanupRegistry is falsy (legacy browsers), the fallback sets onDeviceChange = this.handleDeviceChange — a direct strong reference to the Room instance. The local cleanupController is never registered with a FinalizationRegistry and is never aborted, so the signal passed to addEventListener at src/room/Room.ts:402-404 never fires.

Previously, handleDisconnect contained:

navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);

This line was the only cleanup path for the legacy case. With it removed, the listener persists indefinitely after disconnect, preventing GC of the Room and continuing to invoke handleDeviceChange on a disconnected Room.

(Refers to lines 1865-1869)

Prompt for agents
The removal of `navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange)` from `handleDisconnect` is correct for the modern path (WeakRef + FinalizationRegistry), but creates a regression for the legacy fallback path (src/room/Room.ts:395-399) where `onDeviceChange = this.handleDeviceChange` is used directly without any cleanup mechanism.

To fix this, you need to ensure the legacy path also cleans up on disconnect. Options:
1. Store the `cleanupController` as an instance property and call `cleanupController.abort()` in `handleDisconnect`. This works for both modern and legacy paths (on browsers that support `signal` in addEventListener).
2. Alternatively, only remove the listener in `handleDisconnect` when `Room.cleanupRegistry` is falsy (legacy path), since the modern path handles cleanup via FinalizationRegistry.

Option 1 is cleaner since it works uniformly. The `cleanupController` (currently a local variable in the constructor at line 377) should be promoted to an instance field, and `handleDisconnect` should call `this.cleanupController.abort()`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Memory leak: Room instances remain in the heap after disconnect(), accumulating across repeated join/leave cycles (reproducible on meet.livekit.io)

2 participants