Event Listener Leaks: A Five-Case Simulation Study of How emitter.on() Without off() Degrades Node.js Services

by Ko-Hsin Liang
  • nodejs
  • event-emitter
  • resource-leaks
  • MaxListenersExceeded
  • performance
  • typescript
  • benchmarking
  • simulation
  • empirical-study
  • event-listeners
  • error-handling
  • heap-memory
  • emit-latency
  • closures

Event Listener Leaks: How emitter.on() Without off() Degrades Node.js Services

You’ve seen the warning in Node.js logs:

MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 data listeners added to [EventEmitter]. Use emitter.setMaxListeners() to increase limit.

The standard response is to call emitter.setMaxListeners(20) to silence the warning and move on. That’s the wrong fix — the warning is correct. The emitter is accumulating listeners that are never being removed, and increasing the limit just lets the leak continue further before you notice.

Event listener leaks are unique among Node.js resource leaks because they have three simultaneous failure modes:

  1. MaxListenersExceeded warning — when any single EventEmitter accumulates more than 10 listeners for the same event (the Node.js default)
  2. Heap growth — every leaked listener holds its closure in memory indefinitely
  3. Emit latency degradation — every emitter.emit() call must invoke all registered listeners synchronously; more listeners = higher latency per emit

I built a discrete-event event listener simulator and ran five two-dimensional parameter experiments. The results reveal where each failure mode becomes operationally critical — and correct a common misconception: emitter.once() is not safer than emitter.on() when the event never fires.

Event listener leaks simultaneously produce three distinct failure signals: MaxListenersExceeded warnings (>10 listeners on any single emitter), heap growth from retained closures (57 leaked listeners × 1MB = 57MB heap), and emit latency degradation linear with listener count (1,000 leaked listeners = 30ms per emit; at 1,000Hz = 465 million callbacks per 30 seconds). The MaxListeners threshold is per emitter — concentrating listeners on 1 emitter is 100× more dangerous than spreading across 100. And emitter.once() accumulates listeners identically to on() if the event never fires.


The Pattern

An event listener leak occurs when you register a listener with emitter.on() but never remove it with emitter.off():

// Leaky — common in WebSocket handlers, pub/sub consumers
class DataProcessor {
  constructor(private bus: EventEmitter) {}

  start() {
    // New listener added every time start() is called
    // If start() is called again (reconnect, reinit), listeners accumulate
    this.bus.on('data', (payload) => {
      this.processData(payload); // 'this' captured in closure
    });
  }
  // No stop() method — no way to remove the listener
}

The insidious version with anonymous callbacks:

// Leaky — anonymous function cannot be removed later
function setupStream(stream: Readable, processor: DataProcessor) {
  stream.on('data', (chunk) => {
    processor.handle(chunk); // processor captured in closure
  });
  stream.on('error', (err) => {
    processor.reportError(err); // cannot call stream.off() with anonymous function
  });
}

You cannot remove an anonymous listener because emitter.off(event, callback) requires an exact reference to the function you registered. Once the anonymous function is created inline, it’s anonymous — there’s no way to remove it.

In a service that reconnects WebSocket handlers, reinitializes data consumers, or runs multiple concurrent request handlers sharing an emitter, listeners accumulate on every initialization cycle.


The Simulation

The listener simulator models emitter.on() registration, emitter.emit() dispatch, and emitter.off() removal (or leak). Parameters:

ParameterWhat it controls
leakProbabilityChance a listener is not removed (0–100%)
listenersPerComponentListeners each component registers (1–100)
closureSizeBytes captured per listener closure (0B–1MB)
emitFrequencyHzHow often emitter.emit() is called (1–1,000 Hz)
emitterCountNumber of separate EventEmitter instances (1–100)
listenerTypeon vs once

Metrics collected:

  • Leaked listener count — total registered listeners never removed
  • Heap growth — captured closure memory from leaked listeners
  • Time-to-exhaustion — when MaxListeners threshold is exceeded on any emitter
  • Total callback invocations — all listener callbacks fired during simulation
  • Mean emit latency — milliseconds per emitter.emit() call (proxy for event loop overhead)

1. Leak Probability vs. Listeners Per Component

Time-to-MaxListeners-exceeded

0% leak1%5%10%20%50%100%
1 listener/component∞∞20.9s9.6s5.0s2.1s1.0s
2 listeners/component∞∞13.9s6.4s2.0s1.0s0.5s
5 listeners/component∞∞6.3s2.2s0.9s0.4s0.2s
10 listeners/component∞13.5s2.0s1.0s0.3s0.2s0.1s
20 listeners/component∞4.9s1.0s0.4s0.2s0.1s0ms
50 listeners/component∞2.0s0.4s0.1s0ms0ms0ms
100 listeners/component∞1.0s0.2s0ms0ms0ms0ms

MaxListeners threshold: 10 per emitter (Node.js default).

0ms means MaxListeners is exceeded before the simulation clock advances — the very first leak exhausts the threshold.

At 100 listeners per component with 10% leak: a single component initialization leaks 10 listeners onto the emitter — exactly matching the MaxListeners default. The warning fires immediately.

At 10 listeners per component with 1% leak: TTE 13.5 seconds. The leak is slow but inevitable.

Leaked listener count

1% leak5%10%20%50%100%
1 listener/component4193657140300
10 listeners/component261473085941,4933,000
50 listeners/component1457431,5172,9977,49615,000
100 listeners/component2851,4753,0006,01514,93930,000

At 100 listeners per component with 100% leak (every component fails to clean up): 30,000 leaked listeners over a 30-second simulation.

Heap growth (4KB closure per listener)

1% leak5%10%20%50%100%
1 listener/component16 KB77 KB147 KB234 KB573 KB1.2 MB
10 listeners/component104 KB591 KB1.2 MB2.4 MB5.9 MB11.7 MB
50 listeners/component580 KB2.9 MB5.9 MB11.7 MB29.3 MB58.6 MB
100 listeners/component1.1 MB5.8 MB11.7 MB23.5 MB58.4 MB117 MB

30,000 leaked listeners × 4KB each = 117MB at 100% leak, 100 listeners/component.

Components registering many listeners per lifecycle are extremely dangerous — 10 listeners per mount with 10% leak rate triggers MaxListeners in 1 second. MaxListeners is a leading indicator, not the actual failure; the real damage is heap growth and emit latency. The warning fires early enough to act on — treat it as a critical alert, not noise to suppress with setMaxListeners(100).


2. Closure Size vs. Leak Probability

Heap growth by closure size

0B1KB4KB16KB64KB256KB1MB
0% leak0000000
1% leak (4 listeners)04 KB16 KB64 KB256 KB1.0 MB4.0 MB
2% leak (6 listeners)06 KB24 KB96 KB384 KB1.5 MB6.0 MB
5% leak (19 listeners)019 KB76 KB304 KB1.2 MB4.8 MB19.1 MB
10% leak (36 listeners)036 KB144 KB576 KB2.3 MB9.1 MB35.2 MB
20% leak (57 listeners)057 KB228 KB912 KB3.6 MB14.4 MB57.0 MB

Fixed: 1 listener/component configuration.

Identical behavior to BM-05 timer closures: heap growth = leaked count × closure size, linearly.

The dangerous real-world scenario: listeners capturing React/Vue component state, service objects, or request contexts:

// Captures the entire 'component' object in closure
service.on('userUpdate', (event) => {
  component.state.user = event.user;     // component is ~100KB state tree
  component.renderUserBadge();
  component.notifyChildren(event);
});
// If component.cleanup() doesn't call service.off('userUpdate', handler),
// the component object is retained in the listener closure forever

If component is 100KB and 57 listeners leak over a session: 5.7MB of component state retained — across all active users, this scales to GB.

Same formula as timer leaks: heap = count × closure size. Listeners capturing large service objects are the highest-risk pattern — per-request or per-session listeners that capture full session state are particularly dangerous. Zero-closure listeners cause zero heap growth; the danger is always in what the closure captures.


3. Event Frequency vs. Listener Count

Total callback invocations

1Hz10Hz50Hz100Hz500Hz1,000Hz
1 listener4354,62023,22046,470232,470464,970
10 listeners4,35046,200232,200464,7002,324,7004,649,700
100 listeners43,500462,0002,322,0004,647,00023,247,00046,497,000
500 listeners217,5002,310,00011,610,00023,235,000116,235,000232,485,000
1,000 listeners435,0004,620,00023,220,00046,470,000232,470,000464,970,000

At 1,000 leaked listeners × 1,000Hz emit frequency: 465 million callback invocations over 30 seconds — over 15 million per second. At typical event handler execution times of microseconds, this represents dozens of seconds of CPU time per second. The event loop cannot process anything else.

Contrast with the manageable end: 1 listener × 1Hz: 435 callbacks over 30 seconds — completely invisible.

Mean emit latency

1Hz10Hz50Hz100Hz500Hz1,000Hz
1 listener0.03ms0.03ms0.03ms0.03ms0.03ms0.03ms
10 listeners0.30ms0.30ms0.30ms0.30ms0.30ms0.30ms
50 listeners1.5ms1.5ms1.5ms1.5ms1.5ms1.5ms
100 listeners3.0ms3.0ms3.0ms3.0ms3.0ms3.0ms
500 listeners15ms15ms15ms15ms15ms15ms
1,000 listeners30ms30ms30ms30ms30ms30ms

Mean emit latency scales perfectly linearly with listener count at 0.03ms per listener. Emit frequency has no effect on per-call latency — each emit still invokes all listeners synchronously.

But emit frequency determines the aggregate CPU time:

  • 100 listeners × 1Hz × 30s = 100 × 3.0ms × 30 = 9 seconds of CPU for emits alone
  • 100 listeners × 1,000Hz × 30s = 100 × 3.0ms × 30,000 = 9,000 seconds of CPU — impossible in 30 real seconds

The 1,000Hz case with 100+ listeners is catastrophically over-budget. The event loop can only process ~33,333ms of work per second. 100 listeners × 3ms × 1,000 Hz = 300,000ms of work per second scheduled — 9× CPU capacity. Everything else in the process starves.

Emit latency = 0.03ms × listener count — a deterministic formula. High-frequency emitters are the critical path: 100 leaked listeners on a stream emitting 100 times/second = 300ms/second of extra CPU just for dispatch. The safe zone is under 50 listeners on any high-frequency emitter. Above 100 listeners at 100Hz+, degradation becomes operationally significant. At the Node.js default of 10 listeners × 1,000Hz: 300ms/second — still manageable. At 100 listeners: 3,000ms/second — approaching saturation.


4. Emitter Count vs. Listeners Per Emitter

Time-to-MaxListeners-exceeded

Listeners/emitter1 emitter25102050100 emitters
120.9s∞∞∞∞∞∞
56.3s7.9s12.9s20.7s∞∞∞
102.0s4.1s5.2s14.5s24.3s∞∞
201.0s2.1s3.3s5.7s11.9s20.6s21.4s
500.4s0.4s1.1s1.4s2.1s3.7s5.2s
1000.2s0.3s0.4s0.4s1.0s1.8s1.8s

Fixed: 5% leak probability.

The table reveals the per-emitter nature of MaxListeners: with 1 emitter and 1 listener/emitter at 5% leak: TTE = 20.9 seconds. With 10 emitters and the same configuration: TTE = ∞ (never triggered in 30 seconds) because each emitter only accumulates ~2 leaked listeners (19 total ÷ 10 emitters).

At 50 listeners/emitter with 1 emitter: TTE = 0.4 seconds. With 100 emitters: TTE = 5.2 seconds. More emitters distribute the listener load, but the total damage (heap, callback invocations) is identical — it’s just distributed.

Leaked listener count (independent of emitter count)

Listeners/emitterAny emitter count
119 leaked
573 leaked
10147 leaked
50743 leaked
1001,475 leaked

Total leaked listener count is identical regardless of emitter count. Five percent of (emitter_count × listeners_per_emitter × operations) leak, regardless of how many emitters share those listeners.

MaxListeners is per-emitter, not global. Splitting listeners across more emitters delays the warning but doesn’t reduce total heap or callback overhead. A single shared “bus” emitter is the highest-risk architecture — 100 listeners per component at 10% leak hits MaxListeners immediately. The architectural fix is fine-grained per-instance emitters. Increasing maxListeners delays the warning but doesn’t stop the leak:

// Wrong — silences the warning, doesn't fix the leak
emitter.setMaxListeners(100);

// Right — store the callback and remove it in cleanup
class Component {
  private readonly onDataHandler = (data: unknown) => this.handleData(data);

  mount(emitter: EventEmitter) {
    emitter.on('data', this.onDataHandler); // Stored as class property
  }

  unmount(emitter: EventEmitter) {
    emitter.off('data', this.onDataHandler); // Exact reference — removes correctly
  }
}

5. Listener Type vs. Leak Probability

Leaked listener count comparison

0% leak1%2%5%10%20%50%100%
emitter.on()046193657140300
emitter.once()046193657140300

Identical. The listener type has zero effect on how many listeners accumulate when leaks occur.

Heap growth comparison

0% leak5%10%20%50%100%
emitter.on()077 KB147 KB234 KB573 KB1.2 MB
emitter.once()077 KB147 KB234 KB573 KB1.2 MB

Identical. Both on() and once() retain their closure in heap memory until the listener is either removed or fires.

Time-to-MaxListeners comparison

5% leak10%20%50%
emitter.on()20.9s9.6s5.0s2.1s
emitter.once()20.9s9.6s5.0s2.1s

Identical. The MaxListeners threshold counts all registered listeners, regardless of whether they’re on or once.

When does once() help — and when doesn’t it?

emitter.once() auto-removes the listener after the event fires exactly once. This prevents accumulation only if the event is guaranteed to fire before the component/scope is destroyed.

// SAFE: 'close' event always fires once — once() prevents leak
socket.once('close', () => cleanup());

// LEAKY: if 'authenticated' never fires (connection dropped first),
// the listener accumulates
socket.once('authenticated', (user) => {
  session.user = user; // session captured in closure — retained if never fires
});

In the second example, once() gives false safety. If the connection drops before 'authenticated' fires, the listener is never removed, the session object is permanently retained, and MaxListeners accumulates on the socket emitter.

once() is safe only when the event is guaranteed to fire. For events that may or may not fire (error conditions, race conditions, connection states), treat once() the same as on() — store the reference and call off() explicitly in cleanup.

emitter.once() is not a leak prevention mechanism — it fires once and auto-removes only if the event fires. If the event never fires, it leaks identically to on(). The commonly held belief is wrong: replacing on() with once() doesn’t fix listener leaks. The fix is always emitter.off(event, callback) with a stored reference. Anonymous callbacks are permanently leaked — once written as emitter.on('event', () => { ... }), there is no way to remove them.


What I Found

Event listener leaks are the only resource leak type in this series that simultaneously produces three distinct failure signals:

Failure modeTriggerLatency to first symptom
MaxListeners warningAny emitter accumulates >10 listenersSeconds to minutes
Heap growthClosure retentionGradual over minutes to hours
Emit latency degradation>50 listeners on high-frequency emitterGradual, no hard threshold
FindingData point
MaxListeners default10 per emitter
100 listeners/component × 10% leakMaxListeners exceeded immediately
10 listeners/component × 1% leakMaxListeners in 13.5s
Heap formulaleaked count × closure size
57 leaked listeners × 1MB closure57MB heap growth
Emit latency formula0.03ms × listener count
1,000 listeners × 1,000Hz465M callbacks in 30s, 30ms/emit
MaxListeners is per-emitter1 emitter vs 100 emitters: 5.2s vs ∞ TTE
once() vs on()Identical behavior when event never fires

The Fix

Stored callback reference (required for anonymous-style):

class DataConsumer {
  private readonly onMessage: (msg: Message) => void;

  constructor(private readonly emitter: EventEmitter) {
    // Store the bound method — same reference every time
    this.onMessage = (msg) => this.handleMessage(msg);
  }

  start(): void {
    this.emitter.on('message', this.onMessage);
  }

  stop(): void {
    this.emitter.off('message', this.onMessage); // Exact reference
  }
}

Method binding (TypeScript class pattern):

class RequestHandler {
  // Bound method reference is stable across calls
  private readonly handleData = this.onData.bind(this);

  attach(stream: EventEmitter): void {
    stream.on('data', this.handleData);
    stream.on('end', this.handleEnd);
  }

  detach(stream: EventEmitter): void {
    stream.off('data', this.handleData);
    stream.off('end', this.handleEnd);
  }

  private onData(chunk: Buffer): void { /* ... */ }
  private handleEnd(): void { /* ... */ }
}

Cleanup registry (for complex multi-listener components):

class Component {
  private cleanups: Array<() => void> = [];

  listen(emitter: EventEmitter, event: string, handler: (...args: any[]) => void): void {
    emitter.on(event, handler);
    // Register cleanup
    this.cleanups.push(() => emitter.off(event, handler));
  }

  destroy(): void {
    for (const cleanup of this.cleanups) cleanup();
    this.cleanups = [];
  }
}

// Usage
const comp = new Component();
comp.listen(bus, 'data', (d) => handle(d));
comp.listen(bus, 'error', (e) => report(e));
comp.listen(bus, 'close', () => disconnect());

// On component lifecycle end:
comp.destroy(); // Removes all listeners at once

Detection

Static analysis

# Find emitter.on calls — verify each has a matching off
grep -rn "\.on(" src/ --include="*.ts" | grep -v "\.once\|EventEmitter\|\.on('"

# Count on vs off calls (should be balanced)
grep -c "emitter\.on\b\|\.on('" src/**/*.ts
grep -c "emitter\.off\|removeListener\|removeAllListeners" src/**/*.ts

# Find anonymous arrow functions in event listeners (cannot be removed)
grep -rn "\.on('[^']*', (" src/ --include="*.ts"

Runtime monitoring

// Monitor emitter listener counts
function checkListenerCounts(emitters: Map<string, EventEmitter>): void {
  for (const [name, emitter] of emitters) {
    for (const event of emitter.eventNames()) {
      const count = emitter.listenerCount(event as string);
      if (count > 8) { // Alert before MaxListeners default of 10
        console.warn(`[${name}] Event '${event as string}' has ${count} listeners`);
      }
    }
  }
}

// Override maxListeners to add monitoring
class MonitoredEmitter extends EventEmitter {
  constructor(name: string) {
    super();
    this.setMaxListeners(10); // Keep default
    this.on('newListener', (event, listener) => {
      const count = this.listenerCount(event);
      if (count > 7) {
        console.warn(`[${name}] Adding listener #${count + 1} to '${event}'`);
        // Log stack trace to find the caller:
        console.trace();
      }
    });
  }
}

Caveats

MaxListeners threshold simulation. The simulation models MaxListeners as 10 per emitter (Node.js default). In practice, the warning is emitted but the listener is still registered — MaxListeners is a hint, not a hard limit. TTE in this study represents “when the warning fires,” not “when the emitter stops working.”

Synchronous emit model. Node.js EventEmitter.emit() is synchronous — all listeners execute before emit() returns. This is the model used in the simulation and is accurate for the standard EventEmitter. Async event systems (RxJS observables, async generators) have different scheduling semantics.

Emit frequency modeling. The simulation assumes a fixed emit frequency for the duration. Real systems have variable emit rates — bursty data processing, spike events, idle periods. The emit latency formula (0.03ms × listener count) is a steady-state approximation; bursty emitters may see higher instantaneous latency.


Conclusion: the complete resource leak taxonomy

This is Part 6 and the final installment of the Node.js resource leak study. Across all six benchmark modules:

ModuleResource limitFailure modeKey fix
BM-01 DB PoolPool size (20)Pool exhaustion in secondsclient.release() in finally
BM-02 File FDsOS limit (1,024)EMFILEfd.close() in finally
BM-03 StreamsOS limit + heapEMFILE + OOMstream.destroy() / pipeline()
BM-04 HTTP SocketsAgent pool (50)Socket starvation in secondsreq.destroy() in timeout/error
BM-05 TimersNoneHeap OOM + event loop lagclearInterval() with stored ID
BM-06 ListenersPer-emitter (10)MaxListeners + heap + emit lagemitter.off() with stored reference

The consistent finding across all six: resource leaks interact multiplicatively with workload parameters, and the only reliable mitigation is eliminating the leak at the source.


Try it yourself

git clone https://github.com/liangk/empirical-study.git
cd empirical-study/studies/06-resource-leaks
npm install

# Run all 5 BM-06 experiment cases
npm run experiments:bm06

# Run individual cases
npm run experiments:bm06:case1   # Leak Probability × Target Listener Count
npm run experiments:bm06:case2   # Closure Size × Leak Probability
npm run experiments:bm06:case3   # Event Frequency × Listener Count
npm run experiments:bm06:case4   # Emitter Count × Listeners Per Emitter
npm run experiments:bm06:case5   # Listener Type (once vs on) × Leak Probability

Results are saved to src/step1-benchmarks/experiments/bm06/experiments-bm06-<timestamp>.json.


About this research

This study is part of a series of empirical performance investigations that validate the detection rules used in Code Evolution Lab. Each article in this series:

  • Runs controlled experiments to quantify performance impact
  • Provides open-source simulation code and raw data for reproducibility
  • Maps exact thresholds where anti-patterns become operationally critical

Other studies in this series:

  • N+1 Query Problem — 40 repos, 847 instances, 89× slowdown
  • Blocking I/O — 250 repos, 10,609 instances, 280× throughput penalty
  • Memory Leaks — 500 repos, 55,864 instances, ~8 KB/cycle retained heap
  • Missing Index — 40 Prisma repos, 1,209 missing indexes, 190× slowdown

If you want these checks running automatically on your codebase, check out Code Evolution Lab.


The simulation code, experiment runner, and raw data are on GitHub. Built at StackInsight.