Under The Hood
backend nodejs Node.js 20+ (events, node --inspect)

EventEmitter Listener Leaks, Unclosed Socket Handles, Memory Profiling

Last updated
Prerequisites:
Node.js EventEmitter basics
Understanding of V8 heap and garbage collection
  • nodejs
  • memory-leaks
  • eventemitter
  • listeners
  • heap-snapshot
  • profiling

Read at your depth

The practical view

Common Node.js memory leaks: EventEmitter listeners added but never removed (emitter.on('event', fn) without emitter.off in cleanup), global caches/arrays that grow unboundedly, closures capturing large objects that outlive their use, timers/intervals not cleared, and sockets/streams not closed. Symptoms: heap grows steadily, GC can't reclaim, process OOMs. Detect with: --max-old-space-size to cap, process.memoryUsage(), heap snapshots via node --inspect (Memory tab), and --trace-gc. Fix patterns: always remove listeners (once() or off() in finally), bound the cache, close handles, and null out references when done.

The same idea in other frameworks

react equivalent
N/A — Node.js runtime concern.

Legacy vs modern

Listener added per call vs removed with once()

Adding an EventEmitter listener in a loop without removing it accumulates closures; once() or explicit off() prevents the leak.

before → after
Listener accumulation
function attach() {
  emitter.on('data', (chunk) => processChunk(chunk)); // ❌ OLD: never removed
once() or explicit off() cleanup
emitter.once('data', handler); // ✅ NEW: auto-removed after one fire
// or: emitter.on('data', handler); ... emitter.off('data', handler);

Interview gotchas

Context

This tests the retention model — interviewers want the lexical-environment capture and how a retained closure pins an entire scope, not just the values used.

The mechanical answer

A closure retains its lexical environment (the scope chain): every variable in the captured scopes that the closure could reference is kept alive as long as the closure lives, even if the closure only uses one small value. If the closure is stored in a long-lived structure (an EventEmitter listener, a global callback, a cache), it pins all captured objects — including large buffers, DOM/network state, or entire objects — for the lifetime of the holder. This is why 'I only use this one variable' doesn't prevent leaks: the compiler may keep the whole environment (or, with context allocation, the variables that escape). The leak is visible in a heap snapshot as a retainer path from a root through the closure to the large objects. The fix is to stop holding the closure (remove listeners, null references) or to minimize captured scope.

Trap

The naive answer is 'closures leak only what they use'. They retain the captured environment — potentially much more. Another trap: assuming GC collects a closure whose owner is unreachable — if the owner (emitter/cache) is a root, the closure stays. Candidates who describe the retained scope and the root path show real V8/GC understanding.

Under The Hood — a multi-depth technical interview hub.

Third-party licenses Press ⌘ K to search.