Node.js · advanced

Node.js memory grows from listeners: find the emitter lifecycle mismatch

Guide for diagnosing Node.js heap growth caused by EventEmitter listeners that outlive the request or component that registered them. Explains why setMaxListeners is a tripwire rather than a fix, how to read process.memoryUsage() delta and V8 heap sampling, and how to map retained listeners back to the emitter that created them.

The symptoms

  • RSS and usedHeapSize climb steadily across requests while event-loop lag stays low, indicating retained allocations rather than synchronous work.
  • Node prints MaxListenersExceededWarning for an EventEmitter, often naming the emitter class but not the leak site, after roughly ten listeners accumulate on a single emitter instance.
  • Heap snapshots taken before and after a request burst show an increasing count of listener closure objects anchored to a long-lived emitter, not to the per-request object they were meant to clean up.
  • Function names retained by the heap (visible in retained closures) reference request handlers or callbacks registered inside middleware, intervals, or streams that should have been removed.
  • process.exit on graceful shutdown or a SIGTERM takes noticeably longer as the process ages, consistent with growing listener cleanup work during teardown.

Likely causes

  • An EventEmitter (including http.Server, process, stream instances, or application singletons) is created once at module scope but receives listeners on every request or component, with no matching removeListener or off call.
  • A long-lived interval, timer, or queue keeps a reference to a request-scoped callback, so the callback and its emitter remain reachable after the response is sent.
  • Stream 'data', 'error', or 'close' listeners are attached per request but the stream is not destroyed or the listener array is not cleared when the request finishes.
  • EventEmitter prototype methods are monkey-patched, hiding the normal removeListener contract and producing silent listener retention that only surfaces under heap pressure.
  • setMaxListeners is raised to silence warnings instead of addressing the lifecycle, masking the underlying listener accumulation on a shared emitter.

First ten minutes

  1. 01Capture the current process identifier and a baseline heap reading with process.memoryUsage() before the next request is issued, so the growth delta is attributable.
  2. 02Reproduce the suspected request or component path in isolation: make one call, capture a heap snapshot, repeat, and confirm that usedHeapSize or external memory returns to baseline before the next call.
  3. 03Search stderr and stdout for MaxListenersExceededWarning and capture the emitter identification, event name, and listener count printed at the moment the warning fires.
  4. 04List the emitter instance, event name, and registered listener count with emitter.eventNames() and emitter.listenerCount(eventName) on the suspect emitters, especially http.Server, process, and any application-wide singleton.
  5. 05Take a heap snapshot before and after the repro, then compare retained closures anchored to known long-lived objects rather than to per-request scope.

Evidence to collect

  • process.memoryUsage() values for rss, heapTotal, heapUsed, and external, sampled at fixed points around the repro so the per-request delta is calculable.
  • The exact text and timestamp of any MaxListenersExceededWarning, including the emitter type and the count that triggered the warning.
  • A heap snapshot pair (pre and post) saved to files, with retained closures attributed to a known long-lived emitter rather than the request object.
  • Counts returned by emitter.eventNames() and emitter.listenerCount(eventName) per emitter instance, captured across repeated repro calls to see whether the list resets or grows.
  • The function name and source location (file and line) of retained listener closures, obtained from the snapshot or from a stack trace captured at registration time.

Where to look

  • At the boundary between request scope and module or singleton scope: any place where a request callback calls emitter.on or emitter.once against an object that lives longer than the request.
  • Inside middleware pipelines and routers, where 'finish', 'close', 'data', or domain/error listeners are commonly attached to response, socket, or stream objects.
  • Around timer and queue primitives (setInterval, setImmediate, queue primitives), which frequently retain callback closures across requests if not explicitly cleared.
  • At application bootstrap and dependency injection sites, where shared emitters such as http.Server, process, or event-bus singletons are constructed once and reused.
  • In stream pipelines that consume external sources, where 'data', 'error', 'end', and 'close' handlers compound per connection if streams are not destroyed.

Diagnostic steps

  1. 01Confirm the leak shape by sampling usedHeapSize and external memory across N identical requests and checking whether the delta per request is approximately constant and positive, which distinguishes listener retention from a one-time allocation.
  2. 02Correlate the timing of MaxListenersExceededWarning with the request path by warning timestamps, and check whether the count printed is bounded (suggesting per-instance reuse) or unbounded (suggesting a shared emitter).
  3. 03Differentiate request-scoped emitters from shared emitters: emit on a freshly constructed emitter per request and compare listener counts and heap growth against the same flow using a shared emitter.
  4. 04Use a heap snapshot to enumerate retained closures grouped by their parent object identity; entries whose parent is the same emitter across snapshots indicate a leak rather than transient retention.
  5. 05For each retained listener, capture the registration site by patching EventEmitter.prototype.on during a repro to record stack traces, then verify the site matches a code path that lacks a matching removeListener or off.
  6. 06Rule out non-listener retention by nullifying known heavy caches and timers; if usedHeapSize continues to rise in step with listener count, the listeners are the dominant retained path.

Common mistakes

  • Raising setMaxListeners to suppress MaxListenersExceededWarning without tracking the source; the warning is a tripwire about a shared emitter, not a configurable limit to bypass.
  • Assuming that async/await eliminates the need for explicit listener cleanup; an awaited promise does not remove an 'on' listener, and the closure continues to hold references after the await resolves.
  • Confusing leak shape with workload: a constant per-request heap delta, not total memory, is the signal that retained listeners are accumulating.
  • Attaching 'error' listeners only when a request fails, so the listener count fluctuates and the warning disappears intermittently while retention continues.
  • Treating event-bus or pub/sub layers as zero-cost; they share a single emitter, so even short-lived listeners aggregate into the same listener list until removed.

Safe fixes

  • Once evidence names the emitter and event, remove the listener at the end of the response with emitter.off(event, handler) or by using once when a single fire is intended, conditional on the handler still being registered.
  • When the emitter is per-request (for example a request-scoped event bus), prefer a fresh emitter instance constructed inside the request handler so listeners are collected with the request scope.
  • For streams, call stream.destroy() on completion or error and ensure 'data'/'error' listeners are added with once or removed in 'close', conditional on the stream lifecycle evidence in snapshots.
  • For timers that hold callback references, store the timer handle and call clearInterval or clearTimeout at the same lifecycle boundary where the listener would otherwise be removed.
  • Rather than raising setMaxListeners globally, address the lifecycle so listenerCount returns to baseline between requests; reserve higher limits only for deliberately fan-out patterns with documented listener counts.

Prove the fix

  1. 01Across N identical requests, usedHeapSize and external memory at the end of each iteration return to within a small tolerance of the pre-request baseline, measured by the same sampling points used during diagnosis.
  2. 02emitter.listenerCount(eventName) on the suspect emitter, sampled before and after the repro, is identical, confirming listeners are removed or were never attached to the shared instance.
  3. 03A heap snapshot pair taken after the fix shows no growth in closure objects retained by the previously leaking emitter relative to its baseline count.
  4. 04No additional MaxListenersExceededWarning appears for the previously affected emitter during the same repro window, and the existing warning count does not increase when the workload resumes.
  5. 05Re-running the diagnostic that installed the per-request emitter comparison yields a positive per-request delta only in the shared-emitter path, confirming the fix isolates retention correctly.

Prevention and next steps

  • Adopt a code-review rule that any emitter.on or emitter.once call at module scope or against a singleton must be paired, in the same function, with a matching removeListener or with documented lifecycle ownership.
  • Add a test that exercises a representative request burst and asserts that usedHeapSize and listenerCount per emitter return to baseline within tolerance after the burst completes.
  • Treat MaxListenersExceededWarning as a build or CI failure for shared emitters, so a raised limit is visible in code review rather than hidden in environment configuration.
  • Favor per-request emitter instances or once-style listeners for short-lived subscribers, and reserve setMaxListeners adjustments for components whose fan-out is a documented part of the design.

Safe commands and checks

node -e 'process.on("warning", w => console.log(w.name, w.message)); const e = new (require("events").EventEmitter)(); for (let i = 0; i < 11; i++) e.on("x", () => {});'
node -e 'const e = require("events").EventEmitter.prototype; const orig = e.on; e.on = function(...a){ console.log(new Error("on").stack.split("\n").slice(1,4).join("\n")); return orig.apply(this, a); }; require("./repro-entry");'
node --inspect -e 'setInterval(() => { const m = process.memoryUsage(); console.log(m.heapUsed, m.external, m.rss); }, 1000);'
node --expose-gc -e 'function snap(){ if (global.gc) global.gc(); console.log(JSON.stringify(process.memoryUsage())); } snap();'
node -e 'const e = new (require("events").EventEmitter)(); console.log(e.eventNames(), e.listenerCount("x")); e.on("x", () => {}); console.log(e.eventNames(), e.listenerCount("x"));'