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.

How a listener keeps its owner alive A process-wide bus holds an array of listeners for one event. Requests 1 to 3 finished but never called off, so their listener closures remain in the array and each keeps its request context with parsed body and user record alive. Request 4 is in flight with its listener registered. In the fixed version, finished requests call off and only in-flight requests have listeners. bus (lives forever) _events.update = [ … ] listener → request 1 context listener → request 2 context listener → request 3 context listener → request 4 (in flight) Finished, never off() each closure keeps its request context, parsed body and user record alive 11th listener → warning fixed: off() on finish

Step-by-Step Fix

  1. Get the stack. Run with node --trace-warnings, or listen with process.on('warning', …) and log warning.stack, warning.emitter, warning.type and warning.count. Verification: you know the emitter, the event name and the line that added the 11th listener.
  2. 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.
  3. Remove on the owner’s teardown. Keep a reference to the listener and call emitter.off(event, fn) when the owner finishes: in finally, on close, on response finish. Use once when only the first event matters. Verification: emitter.listenerCount(event) returns to baseline after load.
  4. Use AbortSignal for groups. For EventTargets pass { signal } to addEventListener; for emitters, use events.once(emitter, event, { signal }) or remove listeners in one abort handler. Verification: one abort() removes every listener for the owner.
  5. 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.
  6. Keep the limit meaningful. Raise setMaxListeners only on emitters that legitimately need more, with a specific number. Verification: the default limit applies everywhere else.
Listener count on a shared emitter During a load test, listeners added per request without removal accumulate: the count passes the threshold of 10, where the warning appears once, and keeps growing with every request. With off on finish, the count tracks in-flight requests and returns to zero when traffic stops. 10 warning (once) no off(): grows with requests off() on finish: tracks in-flight, then 0

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.

Keeping listener leaks out of production Fail the test run whenever a MaxListenersExceededWarning is emitted, log listenerCount for important shared emitters in diagnostic builds and compare it with in-flight work, and require a justification comment for every setMaxListeners call. Safeguards Tests fail on the warning process.on(warning) in test setup fails the run. Counts on dashboards listenerCount per shared emitter tracks in-flight work. setMaxListeners reviewed Specific number plus a comment explaining why.

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.