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.
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. 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
emitreorders 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
removeAllListenersalso removes internal listeners that Node may have set on, e.g., streams.
Practical tips
- Prefer composition: a small
EventEmitterfield over class inheritance, so the public API does not accidentally exposeemit. - Use TypeScript with typed emitters or libraries like
tseepto 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.
Related articles
- React React State Colocation Patterns: Where State Should Actually Live
A practical guide to deciding where state belongs in a React app, with patterns for lifting, colocating, and splitting state for performance and clarity.
- Node.js Node.js Async Iterators Tutorial
Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.
- Node.js Node.js Graceful Shutdown Patterns
Implement graceful shutdown in Node.js services with signal handling, connection draining, and timeouts that survive real production deploys.
- Node.js Node.js gRPC Server Tutorial
Build a typed, high-performance gRPC server in Node.js with protobuf definitions, streaming RPCs, and production-ready patterns.