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
- 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.
- 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.
- 03Search stderr and stdout for MaxListenersExceededWarning and capture the emitter identification, event name, and listener count printed at the moment the warning fires.
- 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.
- 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
- 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.
- 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).
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
- 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"));'