MaxListenersExceededWarning and EventEmitter Leaks
Your logs show MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 message listeners added to [EventEmitter]. MaxListeners is 10. Heap grows with traffic, and snapshots show large arrays of functions under an _events property. Or the warning names an AbortSignal or a keep-alive Socket. This guide from Connections, Sockets and EventEmitter Memory, part of Node.js Server-Side Memory Management, shows how to find where the listeners come from and how to remove them properly instead of raising the limit.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Warning names a shared emitter (bus, client, process) |
Listener added per request or per object and never removed | Pair every on with off in the owner’s teardown |
Listener count constant under load |
Warning names a keep-alive Socket |
Per-request listeners added to a reused socket | Attach to the request or response, not the socket; remove on finish | No growth across reused sockets |
Warning names an AbortSignal |
Many operations listen on one long-lived signal | Use per-operation signals, or addAbortListener and dispose |
Bounded abort listeners |
Heap snapshot: big _events arrays retaining closures |
Each listener’s closure keeps its owner alive | Remove listeners; avoid capturing large objects | Owners collected after teardown |
| Warning silenced, memory still grows | setMaxListeners(0) hid the symptom |
Restore the default and find the leak | Warning becomes a useful alarm again |
Root Cause: Long-Lived Emitters, Short-Lived Listeners
An EventEmitter keeps its listeners in arrays keyed by event name. A listener is removed only by off/removeListener, removeAllListeners, or by firing once when registered with once. As long as the emitter is reachable, every registered function is reachable, and every function keeps its closure scope reachable — frequently the request, connection or component that registered it.
The leak pattern is always a lifetime mismatch: a short-lived owner registers on a long-lived emitter and does not unregister. Typical long-lived emitters are a process-wide event bus, a shared database or message-queue client, process itself (exit, SIGTERM, uncaughtException), a keep-alive socket that serves many requests, and a long-lived AbortSignal. Each request or connection adds one listener; after thousands of requests, the emitter holds thousands of closures, as outlined in the connections and sockets topic.
Node warns when an emitter gets more than defaultMaxListeners (10) listeners for one event. The warning is emitted once per emitter and event, so it marks the start of the problem, not its size. It applies to EventTarget too — an AbortSignal with more than 10 abort listeners triggers the same kind of warning. The limit is a heuristic: some emitters legitimately have many listeners, but most warnings in application code point to a missing removal.
Step-by-Step Fix
- Get the stack. Run with
node --trace-warnings, or listen withprocess.on('warning', …)and logwarning.stack,warning.emitter,warning.typeandwarning.count. Verification: you know the emitter, the event name and the line that added the 11th listener. - Identify the lifetimes. Decide which is longer-lived, the emitter or the code adding listeners. Verification: you can say “per-request listener on a process-wide bus” or similar.
- Remove on the owner’s teardown. Keep a reference to the listener and call
emitter.off(event, fn)when the owner finishes: infinally, onclose, on responsefinish. Useoncewhen only the first event matters. Verification:emitter.listenerCount(event)returns to baseline after load. - Use AbortSignal for groups. For
EventTargets pass{ signal }toaddEventListener; for emitters, useevents.once(emitter, event, { signal })or remove listeners in one abort handler. Verification: oneabort()removes every listener for the owner. - Move listeners to the right emitter. Per-request listeners belong on the request or response, not on a reused socket or shared client. Verification: listeners disappear with the request objects.
- Keep the limit meaningful. Raise
setMaxListenersonly on emitters that legitimately need more, with a specific number. Verification: the default limit applies everywhere else.
Command and Code Reference
Use case: log the source of every listener warning.
// warnings.js — load first; gives a stack for each MaxListenersExceededWarning
process.on('warning', (w) => {
if (w.name === 'MaxListenersExceededWarning') {
// w.emitter is the emitter, w.type the event name, w.count the listener count
console.error('listener leak', w.type, w.count, w.emitter?.constructor?.name, w.stack);
}
});
# Or print stacks for all warnings
node --trace-warnings server.js
Use case: a per-request listener on a shared bus, removed on finish.
const bus = require('./bus'); // process-wide EventEmitter
app.get('/orders/:id/stream', (req, res) => {
const onUpdate = (order) => {
if (order.id === req.params.id) res.write(`data: ${JSON.stringify(order)}\n\n`);
};
bus.on('order-updated', onUpdate);
// 'close' fires for normal completion and client disconnects alike
res.on('close', () => bus.off('order-updated', onUpdate));
});
Use case: waiting for one event with a timeout, without leaving a listener.
const { once } = require('node:events');
async function waitForReady(client, ms) {
// once() removes its listener when the event fires or the signal aborts
const [info] = await once(client, 'ready', { signal: AbortSignal.timeout(ms) });
return info;
}
Use case: abort listeners on a long-lived signal.
const { addAbortListener } = require('node:events');
function track(signal, cleanup) {
const disposable = addAbortListener(signal, cleanup); // returns a Disposable
return () => disposable[Symbol.dispose](); // call when the operation finishes
}
Verification and Regression Prevention
A listener leak is fixed when listenerCount for the event tracks in-flight work under load and returns to its baseline when traffic stops, and when a heap snapshot after load shows no large _events arrays retaining request or connection objects. Check each emitter named in past warnings, not only the one you fixed; the same pattern tends to appear in several places.
Treat MaxListenersExceededWarning as an error in tests: a process.on('warning') handler in the test setup that fails the run keeps new leaks from reaching production. Log listener counts for important shared emitters in diagnostic builds, and review any setMaxListeners call for a justification comment.
Edge Cases and Gotchas
The warning fires once
Node emits the warning once per emitter and event name. Absence of repeated warnings does not mean the count stopped growing; measure listenerCount directly.
Listeners that capture little still add up
Even a tiny closure costs memory, and the array holding it grows. More importantly, emitting the event calls every listener, so leaked listeners also cost CPU on every emit.
prependListener and wrapper functions
Libraries sometimes wrap your function before registering it, so off(event, yourFn) does not match. Remove with the same reference that was registered, or use the library’s unsubscribe function.
Streams add their own listeners
pipe adds listeners to both source and destination. Piping many short-lived sources into one long-lived destination without unpipe or pipeline accumulates listeners on the destination.
Frequently Asked Questions
Is MaxListenersExceededWarning an error?
No, it is a warning; nothing fails. But in application code it usually indicates listeners being added without removal, which is a memory and CPU leak.
Should I just call setMaxListeners(0)?
Not as a fix. It removes the warning and leaves the leak. Use a specific higher limit only for emitters that legitimately have many listeners, such as a broadcast emitter with one listener per connected client.
How do I find which code adds the listeners?
Run with --trace-warnings, or handle process.on('warning') and log the warning’s stack. The stack shows the call that added the listener that crossed the limit.
Why does the warning mention a Socket?
Keep-alive sockets serve many requests. Code that adds listeners to req.socket for each request accumulates them on the reused socket. Listen on the request or response instead, or remove the listener when the response finishes.
Why does the warning mention AbortSignal?
A long-lived signal, such as one for the whole process or a long job, received more than 10 abort listeners. Create a signal per operation, or remove listeners with the disposable returned by addAbortListener.
Does removing a listener free memory immediately?
It makes the listener and whatever only it referenced unreachable; the next garbage collection reclaims them. Heap snapshots after a collection confirm it.
Can I see all listeners on an emitter?
Yes. emitter.eventNames() lists the events with listeners, emitter.listenerCount(name) gives the count, and getEventListeners(emitterOrTarget, name) from node:events returns the functions themselves for both EventEmitter and EventTarget. Logging the function names or source locations of a large listener array usually identifies the code that registered them.
Is using once() always safe?
once removes the listener after the first event, but only if that event fires. A once listener waiting for an event that never comes — a response that is never sent, a connection that never becomes ready — stays registered. Combine it with a timeout or an AbortSignal, as events.once(emitter, name, { signal }) does.
Do class instances that extend EventEmitter leak on their own?
No. An emitter and its listeners are collected together once nothing outside references the emitter. The leak needs a long-lived emitter holding listeners from short-lived owners, or a long-lived registry holding the emitters themselves.
Related
- Connections, Sockets and EventEmitter Memory — the parent topic
- WebSocket Server Memory per Connection — per-connection listeners and cleanup
- HTTP Keep-Alive Agents and Socket Pool Memory — reused sockets
- Event Listener Leaks and AbortController Cleanup — the browser side of the same pattern