Free lesson · Node.js Deep Dive
EventEmitter: The Pattern Under Everything
Lesson 23.1 - EventEmitter: The Pattern Under Everything
EventEmitter is the single most important primitive in Node's API design. The HTTP server is an EventEmitter; streams are EventEmitters; process is an EventEmitter; net sockets are EventEmitters. Most Node objects that produce events over time inherit from it. Understanding EventEmitter - its semantics, its failure modes, its memory implications - is foundational to working with Node deeply.
Most engineers learn EventEmitter from a tutorial: "use .on() to listen, .emit() to fire." That's enough to use it; not enough to use it well. The patterns that matter for production code - memory leaks from forgotten listeners, the MaxListenersExceededWarning, when to use EventEmitter versus async iterators or streams - these are what separate working code from code that mysteriously degrades over time.
I work with EventEmitter daily on VS Code Remote at Microsoft. VS Code's entire event system is EventEmitter-based; extensions register listeners; the editor fires events. Memory leaks from forgotten listeners are one of the most common bug patterns in extensions. The discipline matters.
The Core API
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
// Register a listener
emitter.on('event', (data) => {
console.log('Received:', data);
});
// Fire an event
emitter.emit('event', { value: 42 });
// → "Received: { value: 42 }"
// One-time listener
emitter.once('init', () => {
console.log('Initialized');
});
// Remove a listener
const handler = (data) => console.log(data);
emitter.on('event', handler);
emitter.off('event', handler);
// Remove all listeners for an event
emitter.removeAllListeners('event');
// Inspect listeners
emitter.listenerCount('event'); // → number
emitter.listeners('event'); // → array of handlers
The semantics:
- Listeners are functions registered for named events.
emit()calls all registered listeners synchronously, in registration order.once()registers a listener that removes itself after firing.off()removes a specific listener (the same reference passed toon()).removeAllListeners()removes everything for an event (or all events if no name given).
The "synchronous" part is important. When you call emit(), every listener runs to completion (or throws) before emit() returns. If a listener does async work, the work happens but emit() doesn't wait for it. This has implications.
EventEmitter Is Synchronous
A common confusion. EventEmitter dispatches synchronously, but listeners can do async work that completes later.
emitter.on('save', async (data) => {
await saveToDatabase(data);
console.log('Saved');
});
emitter.emit('save', { id: 1 });
console.log('Emit returned');
// Output:
// "Emit returned"
// "Saved" (later, when the DB call completes)
The listener returns a promise, but emit() doesn't await it. By the time the await happens, emit() has already returned.
This means:
Errors in async listeners don't propagate to emit(). If saveToDatabase rejects, the rejection becomes an unhandled promise rejection - not an error at the emit site.
Backpressure doesn't work. If listeners are slow, emit() doesn't wait. Events can stack up faster than they're processed.
Ordering across emits is uncertain. Emit A; emit B. If A's listeners do async work, B's emit may have happened before A's listeners finish.
For these cases, streams or async iterators are better. EventEmitter is for "fire and forget" notifications where each listener handles its own async work independently.
The Memory Leak Pattern
The single most common EventEmitter bug:
flowchart TB
Long["Long-lived EventEmitter<br/>(e.g., global event bus)"]
Many["Many short-lived components<br/>each adding listeners"]
Forgotten["Listeners not removed<br/>when components die"]
Leak["Listener list grows unboundedly<br/>references prevent GC"]
Warning["MaxListenersExceededWarning"]
OOM["Eventually: out of memory"]
Long --> Many
Many --> Forgotten
Forgotten --> Leak
Leak --> Warning
Leak --> OOM
style Forgotten fill:#fbbf24,stroke:#f59e0b,color:#000
style Leak fill:#f87171,stroke:#ef4444,color:#000
style OOM fill:#f87171,stroke:#ef4444,color:#000
A diagram of the EventEmitter memory leak pattern. A long-lived EventEmitter exists - perhaps a global event bus or a singleton like process. Short-lived components throughout the application add listeners to it. When those components are destroyed, they often forget to remove their listeners. The listener list grows unboundedly; each listener holds references to its closure scope, preventing garbage collection of objects that should have been freed. Eventually Node prints the MaxListenersExceededWarning at ten listeners, indicating the leak. If unchecked, the process eventually runs out of memory.
Concrete example:
// BAD: leaks listeners
class RequestHandler {
constructor(private bus: EventEmitter) {
this.bus.on('config-changed', (cfg) => {
this.config = cfg;
});
}
}
// Creating many RequestHandlers without ever removing the listeners
for (const req of requests) {
new RequestHandler(globalBus); // each adds a listener; none cleans up
}
The fix:
class RequestHandler {
private handler = (cfg) => { this.config = cfg; };
constructor(private bus: EventEmitter) {
this.bus.on('config-changed', this.handler);
}
destroy() {
this.bus.off('config-changed', this.handler);
}
}
// Now each handler can be properly cleaned up
const handlers = requests.map(req => new RequestHandler(globalBus));
// Later, when done:
for (const h of handlers) h.destroy();
Two requirements:
- Keep a reference to the handler function. You need the same reference for
off()as foron(). An anonymous arrow function passed toon()can't be removed (you don't have the reference). - Call
off()when the listener should no longer fire. This is often in adestroy(),close(),unmount(), or similar lifecycle method.
For listeners that should fire exactly once, use once() - it auto-removes after firing. For listeners that should fire many times during a bounded lifetime, store the reference and clean up.
The MaxListenersExceededWarning
Node prints a warning if an emitter has more than 10 listeners on any single event:
(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 connection listeners added to [Server]. Use emitter.setMaxListeners() to increase limit
This is Node trying to help - 11 listeners on one event is suspicious. Legitimate cases exist (a chat server with N rooms might legitimately have N listeners), but in most code, it indicates a leak.
When you see this warning:
1. Check for missing off() calls. Most often, this is the cause.
2. If it's legitimate, set a higher limit:
emitter.setMaxListeners(100);
or globally:
EventEmitter.defaultMaxListeners = 100;
But raise the limit only after confirming the high count is intentional. Silencing the warning without understanding why is how leaks accumulate.
3. Inspect the listener list:
console.log(emitter.listeners('event-name'));
// Array of listener functions; can identify which are duplicates
For debugging, this shows what's actually registered. Often reveals the source of unexpected listeners.
Typed Events with TypeScript
By default, EventEmitter is untyped - emit('foo', anything) and on('bar', (any) => {}) compile fine even if there's no 'foo' or 'bar' event. For production code, type safety matters.
The pattern using TypeScript generics:
import { EventEmitter } from 'node:events';
type Events = {
'user-created': [user: { id: string; name: string }];
'user-deleted': [userId: string];
'config-updated': [];
};
class TypedEmitter<E extends Record<string, unknown[]>> extends EventEmitter {
emit<K extends keyof E>(event: K, ...args: E[K]): boolean {
return super.emit(event as string, ...args);
}
on<K extends keyof E>(event: K, listener: (...args: E[K]) => void): this {
return super.on(event as string, listener as any);
}
once<K extends keyof E>(event: K, listener: (...args: E[K]) => void): this {
return super.once(event as string, listener as any);
}
off<K extends keyof E>(event: K, listener: (...args: E[K]) => void): this {
return super.off(event as string, listener as any);
}
}
const bus = new TypedEmitter<Events>();
bus.on('user-created', (user) => {
console.log(user.name); // typed
});
bus.emit('user-created', { id: '42', name: 'Alice' }); // typed args
bus.emit('typo-event', 'bad'); // compile error: not a valid event
This gives compile-time event-name checking and typed handler arguments. For larger codebases, the type discipline catches real bugs.
For libraries, tiny-typed-emitter is a small package providing this pattern without writing it yourself. For internal code, the custom subclass above is fine.
When to Use Streams Instead
EventEmitter is for discrete events: "something happened." Streams are for continuous data: "more bytes available; here they are."
// EventEmitter: discrete events
emitter.on('order-placed', (order) => { /* handle one order */ });
emitter.on('order-placed', (order) => { /* second handler for same order */ });
// Stream: continuous data
readableStream.on('data', (chunk) => { /* one chunk; more coming */ });
readableStream.on('end', () => { /* no more chunks */ });
The key differences:
Backpressure. Streams have a backpressure protocol (.pause(), .resume(), .write() returning false). EventEmitter doesn't - fast producers can overwhelm slow consumers, accumulating events in memory.
Pipe. Streams can be piped (source.pipe(dest)); EventEmitters can't.
End semantics. Streams have explicit end ('end' event, EOF). EventEmitters don't - events stop firing when no more are emitted, but there's no explicit "no more events ever."
For data processing - file reads, network IO, transformations - use streams. For discrete domain events - user signed up, config changed, connection established - use EventEmitter.
When to Use Async Iterators Instead
The modern alternative for many event-stream cases (covered in 23.2). Async iterators give you:
- Pull-based consumption. The consumer asks for the next event; backpressure is natural.
- Promise-based. Errors propagate naturally; await works.
- for-await-of integration. Idiomatic JavaScript syntax.
For an event stream where:
- You want to consume events sequentially (not in parallel handlers).
- You want backpressure.
- You want async/await error handling.
Async iterators are better than EventEmitter.
For an event stream where:
- Multiple handlers should react to the same event.
- Listeners come and go over time.
- Producer and consumers are loosely coupled.
EventEmitter is better.
events.on(emitter, 'event') converts an EventEmitter event stream to an async iterator:
import { on } from 'node:events';
for await (const [data] of on(emitter, 'event')) {
console.log('Received:', data);
}
The combination - an EventEmitter source, an async iterator consumer - is sometimes the right pattern.
Subclassing EventEmitter
For domain-specific events, subclass EventEmitter:
class ConnectionPool extends EventEmitter {
private connections: Connection[] = [];
acquire(): Connection {
const conn = this.findIdle() ?? this.create();
this.emit('acquired', conn);
return conn;
}
release(conn: Connection): void {
this.emit('released', conn);
}
drain(): void {
this.emit('draining');
for (const conn of this.connections) {
conn.close();
}
this.emit('drained');
}
}
const pool = new ConnectionPool();
pool.on('acquired', (conn) => log('Acquired', conn.id));
pool.on('draining', () => log('Pool draining'));
This is the Node convention for objects with lifecycle events. The HTTP server, streams, processes - all subclass EventEmitter for the same reason.
For your own libraries, consider whether the API you're designing has a natural event vocabulary. If "things happen over time" describes the API, EventEmitter is often the right base.
Symbol-Named Events
For events that should be private (not accessible by consumer code), use Symbol-named events:
const INTERNAL = Symbol('internal');
class MyClass extends EventEmitter {
doSomething() {
this.emit(INTERNAL, 'data');
}
// Internal-only listener
constructor() {
super();
this.on(INTERNAL, (data) => { /* internal handling */ });
}
}
// External code can't listen for INTERNAL events
// (they'd need access to the Symbol)
This is rarely needed but useful for libraries that want to use EventEmitter internally while keeping the event namespace clean for consumers.
Error Events
A specific convention: emitting 'error' events.
emitter.on('error', (err) => {
console.error('Error:', err);
});
emitter.emit('error', new Error('Something failed'));
The convention:
1. The first argument is an Error.
2. If no listener is registered for 'error', Node throws the error as an uncaught exception (and the process likely crashes).
const e = new EventEmitter();
e.emit('error', new Error('boom'));
// Crashes the process unless 'error' is listened to
This is intentional - it forces errors to be handled. For long-lived emitters, registering an error handler is non-optional.
For typed events, ensure the error type is in your union:
type Events = {
'error': [error: Error];
// ...
};
once() and Promises
The once() function combined with promises gives you "wait for an event" semantics:
import { once } from 'node:events';
const [data] = await once(emitter, 'ready');
// Waits for the 'ready' event; resolves with its arguments
Useful when you need to wait for an event in async code without setting up a listener manually:
async function waitForServerReady(server) {
await once(server, 'listening');
console.log('Server is listening');
}
Internally, once registers a one-time listener and returns a promise that resolves when it fires. The error handling: if the emitter emits 'error' before the awaited event, the promise rejects.
For "wait for any of multiple events":
const winner = await Promise.race([
once(emitter, 'success'),
once(emitter, 'error'),
]);
This is the bridge between EventEmitter and async/await code. Use it where it fits.
What I Want You to Take From This Lesson
Six things.
One: EventEmitter is the foundation of Node's event-driven API design. Everything inherits from it.
Two: emit() is synchronous; async listeners don't block it. Backpressure isn't built-in.
Three: Memory leaks from forgotten listeners are the most common EventEmitter bug. Always pair on() with off() for long-lived emitters.
Four: MaxListenersExceededWarning is Node telling you to investigate. Don't just raise the limit.
Five: Use streams for continuous data; use async iterators for sequential events; use EventEmitter for discrete fan-out events.
Six: Subclass EventEmitter for domain objects with natural event vocabulary.
Try It
Exercise: Warmup (15 minutes). Build a small event bus with EventEmitter. Register multiple listeners; emit events; remove specific listeners.
Exercise: Core (25 minutes). Reproduce the memory leak pattern. Add many listeners without removal. Observe the
MaxListenersExceededWarning. Then fix the leak by adding proper cleanup.
Exercise: Core (25 minutes). Implement typed EventEmitter with TypeScript generics. Test that compile errors fire for wrong event names and wrong argument types.
Exercise: Stretch (45 minutes). Convert an EventEmitter-based API to async iterators using
events.on(). Discuss which model is better for your specific case.
What's Next
Lesson 23.2 covers async iterators - the modern alternative for many cases where you'd reach for EventEmitter or streams.