Skip to content
C Codeloom
Node.js

Node EventEmitter Patterns

EventEmitter is the backbone of Node. Here are the patterns that make it useful in real systems and the mistakes that turn it into a footgun.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • When to use events instead of callbacks or promises
  • How to avoid memory leaks from listeners
  • Patterns for once, off, and abort signals
  • How to convert events to async iterators
  • When NOT to use EventEmitter

Prerequisites

  • Basic Node.js

EventEmitter is everywhere in Node: HTTP requests, streams, child processes, even the process object itself. Most developers use it without thinking, which is exactly when it bites. This post is about getting the value out of it without the leaks, ordering surprises, and memory creep that come standard.

What EventEmitter is good for

Use events when one producer has many independent observers, or when the lifetime of a thing involves multiple stages (open, data, error, close). Use a promise when there is exactly one outcome. Use a callback when there is exactly one outcome and you cannot afford a microtask.

If your “events” are really a single resolution, you are reaching for the wrong tool. Wrap it in a promise and move on.

Mental model

           emit('data', x)
producer ----------------> listener A
                |------> listener B
                |------> listener C

listeners stay registered until off()/removeListener()
or until the emitter is garbage collected.
EventEmitter fan-out and listener lifecycle

Every on() adds a strong reference from the emitter to the listener. If the emitter outlives the listener’s intended scope, you have a leak. Node warns at 11 listeners by default (“possible EventEmitter memory leak detected”), but the warning is a symptom, not the bug.

Hands-on: a typed emitter with cleanup

const { EventEmitter } = require('node:events');

class Job extends EventEmitter {
  start() {
    setTimeout(() => this.emit('progress', 50), 100);
    setTimeout(() => this.emit('done', { ok: true }), 200);
  }
}

const job = new Job();
const onProgress = (p) => console.log('progress', p);
const onDone = (r) => {
  console.log('done', r);
  job.off('progress', onProgress); // explicit cleanup
};
job.on('progress', onProgress);
job.once('done', onDone);
job.start();

Always pair on with off, or use once when you mean it. The once method auto-removes the listener after the first fire, which solves the most common leak.

Pattern: AbortSignal-aware listeners

Modern Node lets you tie listeners to an AbortController. When the signal aborts, all listeners registered with it are removed automatically.

const ctrl = new AbortController();
job.on('progress', onProgress, { signal: ctrl.signal });
job.on('done', onDone, { signal: ctrl.signal });

// later, one call cleans both up
ctrl.abort();

This is the cleanest pattern for request-scoped listeners. Tie the controller to the request and you cannot leak.

Pattern: events to async iteration

events.on returns an async iterable. This is a clean way to consume a stream of events with normal for await semantics, including cancellation via a signal.

const { on } = require('node:events');

async function consume(job, signal) {
  try {
    for await (const [progress] of on(job, 'progress', { signal })) {
      if (progress >= 100) break;
    }
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

events.once(emitter, 'done') returns a promise resolving on the first emit, which is perfect for “wait until this thing is ready” code without callback nesting.

Pattern: error events are special

If you emit 'error' and nothing listens, Node throws. This is intentional: silent error swallowing in event systems is catastrophic. Always attach an error handler before starting the work, or use events.captureRejections to forward async errors automatically.

const ee = new EventEmitter({ captureRejections: true });
ee.on('work', async () => {
  throw new Error('boom'); // becomes an 'error' event
});
ee.on('error', (err) => console.error('caught', err.message));
ee.emit('work');

Common pitfalls

  • Adding listeners inside a hot loop without removing them. Watch your listener counts with emitter.listenerCount('x').
  • Treating order as guaranteed across listeners. It is by insertion, but a listener that calls emit reorders things in surprising ways.
  • Using events when promises fit. A function that fires 'done' exactly once should return a promise.
  • Cross-process events via process.on('message') without considering that messages are JSON-serialized; functions and Buffers behave unexpectedly.
  • Bumping setMaxListeners(0) instead of fixing the leak. The warning was right.
  • Forgetting that removeAllListeners also removes internal listeners that Node may have set on, e.g., streams.

Practical tips

  • Prefer composition: a small EventEmitter field over class inheritance, so the public API does not accidentally expose emit.
  • Use TypeScript with typed emitters or libraries like tseep to keep event names and payloads honest.
  • For pub/sub across services, do not use EventEmitter; reach for a real message broker. EventEmitter is in-process only.
  • Audit listener counts in tests. Snapshot before and after a request; numbers should match.
  • When in doubt, attach an AbortSignal to every listener. It is the single best leak guard.

Wrap-up

EventEmitter is a small, sharp tool. Used well, it makes long-lived multi-stage things (streams, sockets, jobs) easy to consume. Used badly, it leaks memory in production and produces confusing ordering bugs. Pair on with off, prefer once and AbortSignal, and reach for promises when the shape really is one outcome.