The Check Everyone Skips Before Firing Up Chrome DevTools
Most guides on Node.js memory leak detection in production jump straight to heap snapshots. Fire up --inspect, load test with autocannon, compare two heapdumps, stare at a retainer tree for forty minutes. That workflow works, but it's slow, and you can't run it against a live production pod without exposing an inspector port you probably don't want open.
There's a cheaper check. It takes about ninety seconds and catches the single most common cause of Node.js memory growth: EventEmitter listener accumulation. Before you touch a heap profiler, run this first.
Why EventEmitter Leaks Are the Cheapest Win
Node's core is built on EventEmitter. HTTP request and response objects, streams, database clients, WebSocket connections, process itself — they all extend EventEmitter. Every time you call .on() or .addListener(), you attach a closure that holds references to everything in its scope. Nothing gets garbage collected until that listener is removed.
Here's the part most people miss: Node ships a built-in tripwire for this exact leak. Every EventEmitter instance has a default max listener count of 10. Cross that threshold on a single emitter and Node emits a warning:
(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 listeners added. Use emitter.setMaxListeners() to increase limit
This warning exists specifically to catch listener leaks before they become an OOMKilled pager alert. Most teams either ignore it, or worse, silence it by bumping setMaxListeners(0) — which disables the safety net entirely instead of fixing the underlying bug. Don't do that. Treat the warning as a smoke detector, not noise.
Capture the Warning in Production
You don't need Chrome DevTools to see this. Add a global listener for process warnings and pipe it into your existing logs:
process.on('warning', (warning) => {
if (warning.name === 'MaxListenersExceededWarning') {
console.error({
event: 'listener_leak_warning',
message: warning.message,
stack: warning.stack,
});
}
});
This costs nothing at runtime and requires zero load testing infrastructure. Deploy it once and every future listener leak announces itself in your log aggregator, with a stack trace pointing at the exact .on() call that pushed the count over the edge.
The Manual Audit, When You Suspect but Aren't Sure
If you have a specific emitter you're suspicious of — a shared database client, a message queue connection, a long-lived stream — check its listener count directly:
console.log('req listeners:', someEmitter.listenerCount('data'));
console.log('all events:', someEmitter.eventNames());
Run this before and after a load test against the same endpoint. If the count grows linearly with request volume and never drops, you've found your leak without opening a single heap snapshot. This single check resolves a surprising share of "mystery" memory growth tickets. It's the diagnostic equivalent of checking if the printer is plugged in before calling IT support — unglamorous, but it saves hours.
How the Leak Actually Happens
The pattern is almost always the same shape: a listener gets attached inside something that repeats — a request handler, a loop, a reconnect callback — onto something that persists, like a shared client or the process object itself.
// Leaks: a new listener on every request, on a shared, long-lived emitter
app.get('/status', (req, res) => {
dbClient.on('data', (row) => {
res.write(row);
});
});
dbClient outlives the request. Every hit to /status stacks another data listener on top of it, and each one closes over res from its own request. Those response objects, and everything they reference, can never be collected. RSS ratchets upward one request at a time until the container hits its memory limit.
The same mistake shows up with process.on('SIGTERM', ...) registered inside a factory function that gets called per-connection, or with WebSocket servers that attach a message listener to a shared broadcaster for every client that connects but never detach it on disconnect.
Fix Patterns That Actually Stick
The fix is rarely complicated. It's almost always about scoping the listener's lifetime to match the thing it references.
- Use
.once()when you expect exactly one event. It self-removes after firing, so there's nothing to forget. - Always pair
.on()with.removeListener()(or.off()) in a cleanup path. If you attach a listener in a request handler, remove it when the request ends, usingres.on('close', cleanup). - Prefer
AbortControllerfor cancelable operations. Modern Node APIs likefetchand many stream operations accept asignaloption, which cleans up internal listeners automatically when aborted. - Never attach listeners to shared, long-lived emitters inside per-request code. If you need per-request behavior, keep a local reference and pass data explicitly instead of subscribing.
Here's the corrected version of the leak above:
app.get('/status', (req, res) => {
const onData = (row) => res.write(row);
dbClient.on('data', onData);
res.on('close', () => {
dbClient.removeListener('data', onData);
});
});
One named function reference, one matching removal on connection close. That's the whole fix. No architecture change, no new dependency — just making the listener's lifetime match the request's lifetime instead of the process's.
When the Cheap Check Comes Back Clean
If MaxListenersExceededWarning never fires and manual listener counts stay flat, the leak is somewhere else — closures held in a cache without eviction, a timer that never clears, unbounded arrays collecting request logs in memory. That's when heap snapshots earn their cost.
Take a baseline snapshot right after startup using v8.writeHeapSnapshot(), run a sustained load test, then take a second snapshot. Load both into Chrome DevTools' Memory tab and switch to comparison mode — it shows only objects allocated in the first snapshot that survived into the second. Those survivors are your real leak candidates, and at that point you're looking at retained closures and cache entries rather than dangling listeners.
Tools like clinic.js (clinic heapprofile -- node server.js) are worth reaching for at this stage too, since the flamechart view makes it fast to spot your own functions holding memory they shouldn't. But run the listener check first. It costs a process.on('warning') handler and a log line, versus a full load-testing session with snapshot comparisons.
Preventing It From Coming Back
Once you've fixed a listener leak, stop it from recurring instead of just patching the one instance you found.
- Add the
process.on('warning')capture to every service's startup code, not just the one that broke. - In tests, call
emitter.setMaxListeners()down to a low number deliberately, so a test suite that adds listeners in a loop fails loudly instead of silently. - During code review, treat any
.on()call inside a request handler, loop, or reconnect callback as a question, not a given — ask where the matching removal lives. - Export
heap_used_pctand listener-warning counts to your metrics system, and alert on the ratchet pattern described earlier: heap that GCs but never returns to baseline.
Memory leak detection in production doesn't have to start with a profiler session. Most of the time it starts with a warning Node was already trying to give you.