MaxListenersExceededWarning: Possible EventEmitter Memory Leak: Find the Listener You Never Removed
Node.js · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.
What this solves: Node keeps printing MaxListenersExceededWarning and memory climbs under load. Learn how to find the listener that is added per request and remove it properly.
The Problem
Your Node service starts logging MaxListenersExceededWarning: Possible EventEmitter memory leak detected a few minutes after deploy. Memory rises steadily, about 30 MB an hour, until the container is OOM-killed roughly once a day. The exact line looks like this:
(node:4821) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 order:update listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
In local testing you open two browser tabs and never see the warning. In production, thousands of short-lived requests each add a listener to a long-lived, shared emitter and never take it off. Common shared emitters include:
- an in-process pub/sub bus
- a Redis client
process- a database pool
- a global
AbortSignal
The warning appears once, at the 11th listener. The leak keeps growing silently after that.
Why the Obvious Fix Falls Short
The warning text tells you to call emitter.setMaxListeners(). So most people write bus.setMaxListeners(0) (unlimited) or setMaxListeners(1000), and the warning goes away.
Nothing else changes. The limit of 10 does not cause the leak. It is a smoke detector, and raising it just takes the battery out. Every orphaned listener is a closure that captures whatever it touched, usually req, res, a socket or a buffer. As long as the shared emitter lives, and it usually lives for the whole process, those objects cannot be garbage collected.
The second common move is switching on to once. That only helps if the event is guaranteed to fire for every subscriber. On a pub/sub bus, a client that disconnects before the next order:update keeps its once listener forever.
The third move is calling off() with an inline arrow function. This silently removes nothing, because listeners are matched by function identity.
The warning is legitimately too strict in one case: a fixed, bounded number of listeners that happens to exceed 10, such as 15 plugins each subscribing once at startup. Raising the limit to that known number is correct there. If the listener count grows with traffic, it is always a leak.
How It Actually Works
An EventEmitter stores a plain array of functions per event name. on() pushes onto the array, and off() splices out the entry that is === to the function you pass. Nothing else ever removes entries. When an array's length passes getMaxListeners() (default 10), Node emits the warning once per event name per emitter and keeps adding.
Each listener is a closure, so the emitter indirectly holds a reference to every variable that closure captured.
flowchart LR R1[Request 1] -->|bus.on handler1| B[(Shared bus: listeners array)] R2[Request 2] -->|bus.on handler2| B RN[Request N] -->|bus.on handlerN| B B -->|handler1 closure| RES1[res #1 + socket buffers] B -->|handlerN closure| RESN[res #N + socket buffers] R1 -.->|client disconnects, nothing calls off| X[Request ends] X -.->|but| RES1 GC[Garbage collector] -.->|cannot free: still reachable from bus| RES1
The fix is to give each listener the same lifetime as the thing that owns it:
- For HTTP and SSE, remove the listener on
res's'close'event.'close'fires on both normal completion and client abort.'finish'does not fire on abort. - For async code, use
events.on(emitter, name, { signal })orevents.once(emitter, name, { signal }). Aborting the signal tears the listener down. - For
EventTargetandAbortSignal, pass{ signal }toaddEventListenerso a per-request controller removes it.
To find the culprit, run with node --trace-warnings. The warning then prints the stack of the on() call that crossed the threshold, which is almost always the leaking line.
Before and After
This version leaks: one listener per request on a process-lifetime bus, and nothing ever removes it.
import { EventEmitter } from 'node:events';
export const orderBus = new EventEmitter();
orderBus.setMaxListeners(0); // "fixed" the warning -- actually hid the leak
app.get('/orders/stream', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
// BUG: added per request, captures res, never removed
orderBus.on('order:update', (o) => {
res.write(`data: ${JSON.stringify(o)}\n\n`);
});
});
This version is fixed: the listener's lifetime is bound to the connection, and the default limit is left alone as an alarm.
import { EventEmitter, on } from 'node:events';
export const orderBus = new EventEmitter(); // default limit kept on purpose
app.get('/orders/stream', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
const ac = new AbortController();
res.on('close', () => ac.abort()); // CHANGE: fires on normal end AND client disconnect
try {
// CHANGE: events.on removes its listener when the signal aborts
for await (const [o] of on(orderBus, 'order:update', { signal: ac.signal })) {
res.write(`data: ${JSON.stringify(o)}\n\n`);
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
});
If you prefer callbacks, keep a named reference and use it in res.on('close', () => orderBus.off('order:update', onUpdate)).
When NOT to Use This
- Bounded fan-out above 10. If 20 modules each subscribe once at boot, the count is fixed.
setMaxListeners(20)with a comment explaining why is the right answer. - Cross-process fan-out. If subscribers live on several instances, an in-process
EventEmitteris the wrong tool entirely. Use Redis pub/sub, NATS or a broker, and subscribe once per process instead of once per request. - Huge subscriber counts on one event. With 50k SSE clients, emitting through a 50k-entry listener array is O(n) per event and allocates a closure per client. Keep a
Setof response objects and loop over it yourself. Removal is then a singleset.delete(res).
Gotchas
- Identity matters.
off('x', fn.bind(this))andoff('x', () => ...)remove nothing. Store the exact function reference you passed toon(). onceis not a cleanup strategy. It only cleans up if the event fires. Disconnected clients waiting on a rare event leak just as badly.- The warning fires once. You see one line at 11 listeners and silence at 50,000. Graph
emitter.listenerCount(name)in your metrics instead of relying on logs. - AbortSignal variant.
Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]means a long-lived signal, such as a global shutdown signal, is passed to manyfetchorsetTimeoutcalls whose listeners outlive them. Derive a per-request signal withAbortSignal.any([shutdown, AbortSignal.timeout(5000)])on a current Node release, since older releases had leaks inAbortSignal.anyitself. Or pass the per-request signal instead of the global one. - Library-owned emitters. Calling
client.on('error', ...)on a Redis or DB client inside a request handler is the most common real-world source of this leak. Attach those handlers once at client creation. process.onin tests. Test files that registerprocess.on('unhandledRejection')per test trigger the warning in Jest or Vitest watch mode. Register those handlers in setup files, or remove them inafterEach.
Key takeaway: Run with --trace-warnings to find where the 11th listener was added. Then make that listener's lifetime match the request with off(), a signal option, or events.on with an AbortSignal, instead of raising the limit.
Real-world challenge
An Express service exposes `GET /orders/stream` as Server-Sent Events. Logs show `(node:1) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 order:update listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit`. Heap snapshots taken over 6 hours show thousands of retained `ServerResponse` objects, even though the dashboard only ever has about 40 open streams. A teammate proposes `orderBus.setMaxListeners(1000)`. What is actually wrong, and how do you fix it?
Diagnose
- Restart with
node --trace-warnings server.js. The warning now includes a stack trace pointing at theorderBus.on('order:update', ...)line inside the SSE route. - Log
orderBus.listenerCount('order:update')every minute. The count only goes up and never drops when browser tabs close. 40 open streams should mean 40 listeners, not thousands. - The retained
ServerResponseobjects confirm the cause. Each listener closure capturesres, so a dead connection's response object, its buffers and its socket stay alive for as long as the bus does.
Fix: tie the listener's lifetime to the connection. Raising the limit would only hide the warning while memory keeps climbing.
app.get('/orders/stream', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
const onUpdate = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
orderBus.on('order:update', onUpdate);
// 'close' fires on client disconnect AND on normal end
res.on('close', () => orderBus.off('order:update', onUpdate));
});
Keep the default limit of 10, or set a deliberate number that matches the real concurrency. Then add a metric on listenerCount so any regression shows up as a rising graph instead of a log line nobody reads.