EventEmitter Patterns That Scale
The mandatory 'error' listener, leak warnings, once-as-a-promise, and async iteration.
You know the basics of emit/on. The senior-level material is the sharp edges.
'error' is special
If an emitter emits 'error' and has no listener, Node throws it and can crash the process. Every long-lived emitter needs an error listener, always.
The leak warning is a real signal
Adding more than defaultMaxListeners (10) listeners for one event logs a MaxListenersExceededWarning. Nine times out of ten this is a genuine leak — you are subscribing inside a request handler and never removing them. Do not just raise the limit; find the missing off().
const { EventEmitter } = require('node:events');
const bus = new EventEmitter();
bus.on('error', (err) => console.error('bus error:', err.message));
bus.emit('error', new Error('safe: has a listener'));Promise and async-iterator bridges
events.once(emitter, 'name', { signal }) awaits the next emission as a promise (with cancellation). events.on(emitter, 'name') returns an async iterator, letting you consume a stream of events with for await — clean backpressure-free consumption of discrete events.
Example
// A typed job runner built on EventEmitter, consumed as an async stream,
// with signal-based teardown so no listener outlives the consumer.
const { EventEmitter, on, once } = require('node:events');
class JobQueue extends EventEmitter {
push(job) { queueMicrotask(() => this.emit('job', job)); return this; }
finish() { this.emit('done'); }
}
const q = new JobQueue();
const ac = new AbortController();
// Consume events as an async iterable. The loop ends when 'done' fires
// (we abort) or the signal is triggered elsewhere.
queueMicrotask(async () => {
try {
for await (const [job] of on(q, 'job', { signal: ac.signal })) {
console.log('processing', job.id);
}
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
console.log('consumer detached, zero leaked listeners');
});
once(q, 'done').then(() => ac.abort()); // stop iteration cleanly
q.push({ id: 1 }).push({ id: 2 });
setTimeout(() => q.finish(), 50);When to use it
- A plugin system lets third-party plugins register handlers via `.on('hook:beforeSave', fn)` without modifying the core codebase.
- A pub/sub service routes messages from different channels by emitting channel-specific events so only subscribed listeners are invoked.
- A connection pool emits `'acquire'`, `'release'`, and `'error'` events so monitoring middleware can track pool health without coupling to pool internals.
More examples
Wildcard events with eventemitter2
Uses `eventemitter2` wildcards so one listener catches all `user.*` events without registering each variant.
const { EventEmitter2 } = require('eventemitter2');
const ee = new EventEmitter2({ wildcard: true });
ee.on('user.*', (data) => console.log('User event:', data));
ee.emit('user.created', { id: 1 });
ee.emit('user.deleted', { id: 1 });Limit max listeners to avoid leaks
Adjusts the max listener limit to suppress false memory-leak warnings when a high number of listeners is expected.
const { EventEmitter } = require('events');
const ee = new EventEmitter();
// Node warns when > 10 listeners are added (default)
ee.setMaxListeners(50); // raise limit for known high use
// Or check at runtime
console.log(ee.getMaxListeners()); // 50
console.log(ee.listenerCount('data')); // 0Remove a listener cleanly
Uses `ee.off()` (alias for `removeListener`) to unsubscribe a named function and prevent memory leaks.
const { EventEmitter } = require('events');
const ee = new EventEmitter();
function onData(chunk) {
console.log('chunk:', chunk.length);
}
ee.on('data', onData);
// ... later ...
ee.off('data', onData); // same as removeListener
console.log(ee.listenerCount('data')); // 0
Discussion