Memory Leaks from Listeners
Every on without matching off keeps closures alive in long-running Node servers - use AbortSignal, explicit removal, and heed MaxListenersExceededWarning before heap growth becomes an incident.
Recipe
import { EventEmitter } from 'node:events';
const controller = new AbortController();
const { signal } = controller;
function onAbortable(emitter: EventEmitter, event: string, fn: () => void) {
emitter.on(event, fn);
signal.addEventListener('abort', () => emitter.off(event, fn), { once: true });
}
// cleanup: controller.abort();When to reach for this:
- Request-scoped subscriptions on global emitters
- WebSocket
messagehandlers per connection - File watchers and
process.onhooks in tests - Hot reload dev servers attaching duplicate listeners
Working Example
import { EventEmitter } from 'node:events';
const globalBus = new EventEmitter();
function handleRequest(requestId: string): void {
const controller = new AbortController();
const onJob = (id: string) => {
if (id === requestId) console.log('job done', id);
};
globalBus.on('job:done', onJob);
controller.signal.addEventListener('abort', () => {
globalBus.off('job:done', onJob);
});
// simulate response finish
setTimeout(() => controller.abort(), 100);
}
for (let i = 0; i < 5; i++) handleRequest(`req-${i}`);// Detect leaks in dev
process.on('warning', (w) => {
if (w.name === 'MaxListenersExceededWarning') {
console.error('Listener leak suspected:', w.message);
}
});What this demonstrates:
- Request-scoped handler must detach when request completes
AbortSignalcentralizes cleanup for multiple listenersMaxListenersExceededWarningonglobalBusmeans duplicateonwithoutoffprocess.on('warning')surfaces listener threshold breaches
Deep Dive
How It Works
- EventEmitter stores listener arrays per event name in an internal
eventsmap. - Closures capture
req,socket, or large graphs - GC cannot collect until listener removed. - WeakRef patterns rarely apply - explicit removal is the norm.
- Node internals -
server.on('connection')is fine; per-connectiononon global singleton is not.
Cleanup Strategies
| Pattern | Use |
|---|---|
off with fn reference | Known handler |
AbortSignal | Request/socket scope |
once | Single fire |
removeAllListeners | Shutdown only |
TypeScript Notes
import { on } from 'node:events';
// Async iterator auto-cleans when loop breaks with return
async function drain(emitter: NodeJS.EventEmitter, event: string, signal: AbortSignal) {
for await (const [payload] of on(emitter, event, { signal })) {
console.log(payload);
}
}Gotchas
- Anonymous arrow functions in
on- cannotofflater. Fix: named function or AbortSignal wrapper. - Global bus per handler registration - N requests → N permanent listeners. Fix: scoped emitter per connection or off on close.
- Raising maxListeners to silence warnings - treats symptom not cause. Fix: fix removal first.
- Tests without
EventEmitter.removeAllListeners- flaky subsequent tests. Fix:afterEachcleanup. - Async listeners throwing - unhandled rejection separate from leak but still fatal. Fix: internal try/catch.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| BullMQ / queue | Cross-process events | Same-process microsecond latency |
| RxJS Subject | Complex stream operators | Simple one-to-many notify |
| Direct function call | Known single consumer | Unknown fan-out set |
events.on async iterator | Consume until done | Need emit to many unknown listeners |
FAQs
What triggers MaxListenersExceededWarning?
More than 10 listeners for one event on one emitter by default - often duplicate registration.
Is setMaxListeners(0) unlimited?
Yes in Node - dangerous in production; fixes leak warnings without fixing leaks.
How do I find leak source?
Log emitter.eventNames() and listenerCount('event') periodically in staging.
Does once prevent all leaks?
Only for one-shot - repeated subscriptions still need off per scope.
WebSocket on('message') pattern?
socket.on('message', handler) and socket.off on close - or AbortSignal per connection.
process.on('SIGTERM') leak?
Single listener for app lifetime is OK - duplicate on hot reload is not.
How does NestJS handle this?
Module destroy hooks should unsubscribe - verify @OnEvent handlers scope.
Are EventEmitter leaks common?
Yes in long-lived APIs attaching to singletons - top cause of slow memory growth.
Does cluster share emitters?
No - per process. Leaks are per worker - multiply by worker count.
WeakMap for listeners?
Rare pattern - explicit off is simpler and clearer in code review.
Testing listener cleanup?
Assert listenerCount returns to baseline after simulated request end.
Relation to domain module?
domain module deprecated - use AsyncLocalStorage + explicit cleanup instead.
Related
- EventEmitter Basics - on/once/emit
- Typed EventEmitter - typed wrappers
- Memory Leak Hunt - heap profiling
- Graceful Shutdown - remove listeners on SIGTERM
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.