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

Example · javascript
// 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.

Example · js
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.

Example · js
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')); // 0

Remove a listener cleanly

Uses `ee.off()` (alias for `removeListener`) to unsubscribe a named function and prevent memory leaks.

Example · js
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

  • Be the first to comment on this lesson.