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:

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:

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

Gotchas

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

  1. Restart with node --trace-warnings server.js. The warning now includes a stack trace pointing at the orderBus.on('order:update', ...) line inside the SSE route.
  2. 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.
  3. The retained ServerResponse objects confirm the cause. Each listener closure captures res, 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.