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
MaxListenersExceededWarningon 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
- Can you confirm this is a bug (the per-negotiation
onceleaking when the answer has no video m-line)? - Is it already fixed in a newer release? If so, which version?
- Is
room.engine.pcManager.publisher.setMaxListeners(0)the recommended interim workaround? Is there a supported API rather than reaching intoengine.pcManager.publisher, and must it be re-applied after a full reconnect (sincepcManager/publisherare recreated on rejoin)? - 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?