rtpVideoPayloadTypes listener leak on publisher PCTransport during audio-only renegotiations

Summary

In livekit-client@2.17.2, when the local participant never publishes a video track (camera off — e.g. an audio-only (call), every publisher renegotiation permanently leaks one rtpVideoPayloadTypes listener on the publisher PCTransport. After 10 renegotiations the transport hits Node’s default EventEmitter limit and logs:

livekit-client.esm.mjs:8215 MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 rtpVideoPayloadTypes listeners added. Use emitter.setMaxListeners() to increase limit
    at PCTransport.addListener (livekit-client.esm.mjs:8361)
    at PCTransport.once (livekit-client.esm.mjs:8390)
    at RTCEngine.<anonymous> (livekit-client.esm.mjs:20973)   // negotiate()

The application registers no such listener — the leak is entirely internal to livekit-client. With the camera on, the leak does not occur.

Environment

Field Value
Library livekit-client@2.17.2
Platform Web (browser, Chrome)
App stack Angular SPA + zone.js
Component RTCEngine / publisher PCTransport

Root cause

Traced in node_modules/livekit-client/dist/livekit-client.esm.mjs (2.17.2).

1. A listener is added on every negotiation. RTCEngine.negotiate() registers a once on the publisher transport each call:

// ~ line 20973 — RTCEngine.negotiate()
this.pcManager.publisher.once(PCEvents.RTPVideoPayloadTypes, rtpTypes => {
  const rtpMap = new Map();
  rtpTypes.forEach(rtp => {
    const codec = rtp.codec.toLowerCase();
    if (isVideoCodec(codec)) rtpMap.set(rtp.payload, codec);
  });
  this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap);
});

2. The finally does not remove it. Only the Closing/Restarting handlers are cleaned up:

// ~ line 21002
} finally {
  this.off(EngineEvent.Closing, handleClosed);
  this.off(EngineEvent.Restarting, handleClosed);
  // rtpVideoPayloadTypes `once` is NOT removed here
}

3. The event fires only on an answer that has a video m-line — the sole emit site, gated on sd.type === 'answer' AND media.type === 'video':

// ~ line 16768
} else if (sd.type === 'answer') {
  const sdpParsed = libExports.parse(sd.sdp);
  sdpParsed.media.forEach(media => {
    if (media.type === 'video') {
      this.emit(PCEvents.RTPVideoPayloadTypes, media.rtp);
    }
  });
}

4. No other emit or removal exists. Grepping the whole bundle for RTPVideoPayloadTypes yields only three hits: the constant, the single emit above, and the once registration. There is no off/removeListener for it anywhere.

Conclusion: With no published video track, no answer ever contains a video m-line, so the once never fires and is never removed. Each publisher renegotiation (publishTrack / unpublishTrack / any negotiationneeded) leaks one listener.

Why the cap is 10: PCTransport extends EventEmitter and keeps Node’s default of 10. SignalClient, RTCEngine, and Room each call setMaxListeners(100), but the PCTransport is never raised — so the warning trips on the 11th leaked listener.

Why it’s audio-only specific

Scenario Answer has video m-line? once fires & auto-removes? Leak?
Camera on Yes Yes No
Camera off / audio-only No No Yes — 1 per renegotiation

Also note: the once is added on pcManager.publisher only, so a remote/agent participant publishing audio (handled on the subscriber transport) does not contribute — this is driven purely by the local participant’s own publish/unpublish.

Reproduction

import { Room, createLocalTracks } from 'livekit-client';

const room = new Room();
await room.connect(url, token);            // camera OFF — no video track published
const pub = room.engine.pcManager.publisher;

for (let i = 0; i < 12; i++) {
  const [track] = await createLocalTracks({ audio: true, video: false });
  await room.localParticipant.publishTrack(track);
  await room.localParticipant.unpublishTrack(track.mediaStreamTrack, true);
  console.log(i, pub.listenerCount('rtpVideoPayloadTypes')); // grows 1,2,3… never drops
}
// Around i = 10 → MaxListenersExceededWarning

Impact

  • MaxListenersExceededWarning on every audio-only call.
  • Unbounded growth of dead listeners (one closure per renegotiation) on the publisher transport for the lifetime of the peer connection — a small but real memory leak that accumulates with every mute/unmute until the PC is recreated or the call ends.

Suggested fix

Tear down the listener when the negotiation settles — capture the handler and remove it in negotiate()'s finally:

const onRtpTypes = rtpTypes => { /* ...emit RTPVideoMapUpdate... */ };
this.pcManager.publisher.once(PCEvents.RTPVideoPayloadTypes, onRtpTypes);
try {
  await this.pcManager.negotiate(abortController);
  resolve();
} catch (e) {
  /* ... */
} finally {
  this.off(EngineEvent.Closing, handleClosed);
  this.off(EngineEvent.Restarting, handleClosed);
  this.pcManager.publisher.off(PCEvents.RTPVideoPayloadTypes, onRtpTypes); // add this
}

(Alternatively, raise setMaxListeners on PCTransport to match Room/RTCEngine — but that only hides the symptom.)

Questions

  1. Can you confirm this is a bug (the per-negotiation once leaking when the answer has no video m-line)?
  2. Is it already fixed in a newer release? If so, which version?
  3. Is room.engine.pcManager.publisher.setMaxListeners(0) the recommended interim workaround? Is there a supported API rather than reaching into engine.pcManager.publisher, and must it be re-applied after a full reconnect (since pcManager/publisher are recreated on rejoin)?
  4. For mute in an audio-only + server-side agent setup, do you recommend setMicrophoneEnabled(false) / track.mute() over unpublish/publish to avoid this renegotiation churn? Any downsides?

@Boussaid_Mohamed, your diagnosis is correct. The leak is still present on main as of today. RTCEngine.ts line 1750 still registers this.pcManager.publisher.once(PCEvents.RTPVideoPayloadTypes, ...) and the finally block at line 1782 still removes only the Closing and Restarting handlers, never the rtpVideoPayloadTypes once [ livekit/client-sdk-js/src/room/RTCEngine.ts ]. Latest npm is livekit-client@2.19.1 (2026-05-29); updating won’t fix it.

Recent neighborhood PRs didn’t touch this case. PR #1944 (May 20) fixed a separate devicechange listener leak via WeakRef [ livekit/client-sdk-js#1944 ]; PR #1927 (April 30) added negotiation tracking by offerId but explicitly kept RTPVideoPayloadTypes intact [livekit/client-sdk-js#1927]. So no in-flight fix for your case. Your patch is the right shape (capture the handler, .off() it in finally). Open a PR with your diff, that file gets few drive-by contributions and your fix is mechanical enough to land quickly.

For the workaround, setMaxListeners(0) on room.engine.pcManager.publisher works as interim, but yes, it must be reapplied after full reconnect since publisher is recreated. For the mute pattern in audio-only + agent flows, setMicrophoneEnabled(false) (or track.mute()) is preferable over unpublish/publish: muting toggles the enabled flag without triggering a renegotiation, while unpublish/publish forces a full publisher negotiation each cycle, which is exactly what creates the leaked listener.

@Boussaid_Mohamed Thank you for raising this. I see CWilson notified the client team about this yesterday and it was taken up. Please follow the PR that Lukas linked below for updates :lk-launch:

thanks for the report, we’ve opened a PR with a fix: fix: rtpMap event leak on multiple negotiations without video tracks by lukasIO · Pull Request #1961 · livekit/client-sdk-js · GitHub