EventEmitter in Production: The Patterns Most Node.js Teams Get Wrong
Node.js EventEmitter is in every framework and ORM you use, but most teams misuse it in production. Here is how to handle async errors in event listeners, prevent memory leaks with typed events, avoid the maxListeners warning trap, and build a domain event bus that survives real traffic.
You are using EventEmitter right now. Every req.on('data') in Express, every process.on('uncaughtException'), every Stream 'data' listener, every database ORM lifecycle hook --- they all sit on top of Node.js’s EventEmitter. It is the single most pervasive abstraction in the platform, and it is also the one most teams get quietly wrong in production.
Three bugs, in order of how much damage they cause:
- An async event handler throws after the
emit()call returns. The error is unhandled, the process crashes, and the stack trace points to nothing useful because the async continuation unwound long ago. - A module registers a listener on a global emitter and forgets to clean it up. The listener accumulates on every hot reload or test run. Memory grows, behavior duplicates, and by the time someone notices, the emitter has 4,000 listeners for the same event.
- A team builds a beautiful event-driven architecture with custom event classes, domain events, and a central event bus. It works perfectly in development. In production, a single misconfigured listener takes down the entire bus, and nobody can figure out why the event chain broke.
This post fixes all three. It covers EventEmitter patterns that survive production traffic, typed events with TypeScript, async error handling, memory management, and when to reach for something other than EventEmitter entirely.
The EventEmitter contract you signed
Before the patterns, a refresher on what EventEmitter guarantees and what it does not.
EventEmitter is synchronous. When you call emitter.emit('data', payload), every registered listener runs in the same tick, in registration order. If a listener throws synchronously, the error propagates out of emit(). No more listeners run. Your code catches it or crashes. This is well-defined and usually fine.
The trouble starts when listeners are async:
emitter.on('order.placed', async (order) => {
await sendEmail(order); // Throws after the emit() returns
await updateInventory(order); // Never runs
});
emit() does not await the returned promise. The async function starts, hits the first await, yields to the microtask queue, and the emit() call returns immediately. If sendEmail rejects, the rejection is an unhandled promise rejection because nobody owns that promise. Node.js will log it, and eventually (depending on version and --unhandled-rejections mode) it may crash the process.
This is bug number one, and it is the most common EventEmitter mistake in production Node.js code.
Pattern 1: Wrapping emit to handle async errors
The fix is to wrap emit() so it collects returned promises and attaches error handling. You do not need a library. A subclass with a single method override does it:
import { EventEmitter } from 'events';
export class SafeEventEmitter extends EventEmitter {
emit(event: string | symbol, ...args: unknown[]): boolean {
// Call the parent emit first so synchronous errors still propagate
const result = super.emit(event, ...args);
// Collect any promises returned by listeners and attach error handling
const listeners = this.rawListeners(event);
for (const listener of listeners) {
const listenerResult = (listener as any)(...args);
if (listenerResult instanceof Promise) {
listenerResult.catch((err: Error) => {
this.emit('error', err);
});
}
}
return result;
}
}
This is deliberately simple. It fires each listener function again to capture the return value. That is O(n) per emit, which is fine for the typical listener count (single digits). If you have hundreds of listeners on a single event, you have a different problem (discussed later under the memory leak section).
A better version that does not call listeners twice uses a private symbol to track which returns are already captured:
const kAsyncResults = Symbol('asyncResults');
export class SafeEventEmitter extends EventEmitter {
override emit(event: string | symbol, ...args: unknown[]): boolean {
const listeners = this.rawListeners(event);
const results: Array<Promise<unknown>> = [];
for (const listener of listeners) {
const result = (listener as any)(...args);
if (result instanceof Promise) {
results.push(result);
}
}
for (const promise of results) {
promise.catch((err: Error) => {
this.emit('error', err);
});
}
return !listeners.includes((l: any) => l.listener !== undefined && l.listener === undefined);
}
}
The key line is promise.catch(...). It ensures that no async rejection goes unhandled, regardless of whether the caller awaits the emit. The error is redirected to the emitter’s 'error' event, which has its own contract (discussed next).
Pattern 2: The error event contract and why you must always listen to it
EventEmitter has a special 'error' event. If you emit an error and no listener is registered for it, the emitter throws the error. If the emitter is attached to a process domain (deprecated but still used in some codebases) or is the process itself, the process crashes.
The rule is simple: every emitter that can emit errors must have an 'error' listener, or it must be created with captureRejections: true (available since Node.js 16).
const dbEmitter = new SafeEventEmitter();
// This crashes the process if dbEmitter emits 'error' and no listener
dbEmitter.on('query.error', (err) => {
logger.error({ err }, 'Database query failed');
});
// This prevents the crash
dbEmitter.on('error', (err) => {
logger.error({ err }, 'Emitter-level error');
});
Node.js 16 introduced captureRejections, which automatically catches promise rejections from async listeners and emits them as 'error' events. Combined with a SafeEventEmitter, you get double coverage:
const emitter = new SafeEventEmitter({ captureRejections: true });
With this flag, Node.js internally wraps the listener’s return value and emits 'error' on rejection. The SafeEventEmitter above then adds a second layer of protection for any promises that slip through. Belt and suspenders, but for error handling, that is the right amount.
Pattern 3: Typed events with TypeScript
The vanilla EventEmitter types are any-ridden. You register a listener for 'data' and receive ...args: any[]. TypeScript cannot help you if you pass the wrong argument shape. A typed wrapper fixes this:
type EventMap = Record<string, unknown[]>;
export class TypedEventEmitter<T extends EventMap> extends EventEmitter {
emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
return super.emit(event as string, ...args);
}
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.on(event as string, listener as (...args: unknown[]) => void);
}
once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.once(event as string, listener as (...args: unknown[]) => void);
}
off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.off(event as string, listener as (...args: unknown[]) => void);
}
}
Usage:
interface OrderEvents {
'order.placed': [orderId: string, customerEmail: string];
'order.shipped': [orderId: string, trackingNumber: string];
'order.cancelled': [orderId: string, reason: string];
'error': [error: Error];
}
const orderBus = new TypedEventEmitter<OrderEvents>();
// TypeScript knows the argument types
orderBus.on('order.placed', (orderId, customerEmail) => {
// orderId is string, customerEmail is string
});
orderBus.emit('order.placed', 'ord_123', 'user@example.com');
// Type error: orderBus.emit('order.placed', 42); // number is not string
Combine this with the SafeEventEmitter from Pattern 1 and you have a typed, async-safe event emitter that eliminates entire categories of bugs. The TypeScript compiler catches argument mismatches at build time, and the safe emit catches async rejections at runtime.
Pattern 4: Memory management (the one nobody does)
The EventEmitter memory leak warning (MaxListenersExceededWarning) is a symptom, not the disease. The disease is that something is adding listeners faster than it removes them. The most common cause is a module that registers a listener on a long-lived emitter inside a short-lived scope.
// Bad: listener registered on every request
app.get('/api/orders', (req, res) => {
orderBus.on('order.placed', (orderId) => {
log.debug(`Order placed: ${orderId}`);
});
// ...
});
Every request adds a listener. If you handle 10,000 requests per minute, you add 10,000 listeners per minute. The emitter’s listener array grows without bound. Even if each listener is a no-op (just a log line), the iteration cost of emit() grows linearly with the number of listeners. Eventually the MaxListenersExceededWarning fires at 11 (or your custom limit), production log monitoring catches it, and someone files a ticket.
The fix is to ask: does this listener belong on the emitter at all? If the answer is “no, it belongs on the request or the response,” use once or req.once:
// Better: scoped to the request lifecycle
app.get('/api/orders', (req, res) => {
const onOrderPlaced = (orderId: string) => {
log.debug({ orderId }, 'Order placed');
};
orderBus.once('order.placed', onOrderPlaced);
res.on('close', () => orderBus.off('order.placed', onOrderPlaced));
});
The res.on('close') cleanup is not optional. Without it, if the event never fires, the listener leaks. Always pair on with off when the listener is scoped to a request, a transaction, or any ephemeral context.
For testing, the same pattern applies:
afterEach(() => {
emitter.removeAllListeners(); // Clean slate
});
This is the single biggest EventEmitter anti-pattern in tests: tests that register listeners on a shared emitter and never clean up. The result is that test B’s listeners fire during test A’s emit, test B’s assertions fail, and hours are spent debugging a phantom race condition that is just stale listeners.
If you use a test framework with per-file isolation (like Jest’s --isolateModules or Vitest’s default worker-per-file), this is less of a problem, but it still bites on any shared module-level emitter.
Pattern 5: Graceful shutdown and event drain
When your service receives a SIGTERM, you need to stop accepting new events, let in-flight event handlers finish, and then close the emitter. EventEmitter has no built-in drain mechanism. You build one:
class DrainingEventEmitter extends SafeEventEmitter {
private inflightCount = 0;
private drainPromise: Promise<void> | null = null;
private drainResolve: (() => void) | null = null;
async emitAsync(event: string, ...args: unknown[]): Promise<void> {
this.inflightCount++;
try {
await super.emit(event, ...args);
} finally {
this.inflightCount--;
if (this.inflightCount === 0 && this.drainResolve) {
this.drainResolve();
this.drainPromise = null;
this.drainResolve = null;
}
}
}
async drain(): Promise<void> {
if (this.inflightCount === 0) return;
this.drainPromise = new Promise((resolve) => {
this.drainResolve = resolve;
});
return this.drainPromise;
}
}
Usage during shutdown:
async function shutdown() {
logger.info('Starting graceful shutdown');
// Stop accepting new emitAsync calls
emitter.removeAllListeners();
// Wait for in-flight handlers to finish
await emitter.drain();
logger.info('All event handlers drained, exiting');
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
This replaces the raw emit() call with emitAsync() everywhere in your application. It is a small cost --- wrapping every emit in a promise --- but it guarantees that your shutdown does not cut off event handlers mid-flight.
Pattern 6: Building a domain event bus
When you have multiple services or modules that need to react to the same domain events (order placed, user registered, payment received), a central event bus is tempting. It is also a single point of failure if not built carefully.
A minimal domain event bus that avoids the common pitfalls:
interface DomainEvent {
type: string;
aggregateId: string;
timestamp: number;
data: unknown;
}
class DomainEventBus {
private readonly emitter = new SafeEventEmitter({ captureRejections: true });
publish(event: DomainEvent): void {
this.emitter.emit(event.type, event);
// Also emit a wildcard so subscribers can catch all events
this.emitter.emit('*', event);
}
subscribe<T extends DomainEvent>(
eventType: string,
handler: (event: T) => void | Promise<void>,
): () => void {
this.emitter.on(eventType, handler as (...args: unknown[]) => void);
// Return an unsubscribe function
return () => {
this.emitter.off(eventType, handler as (...args: unknown[]) => void);
};
}
subscribeAll(handler: (event: DomainEvent) => void | Promise<void>): () => void {
this.emitter.on('*', handler as (...args: unknown[]) => void);
return () => {
this.emitter.off('*', handler as (...args: unknown[]) => void);
};
}
async drain(): Promise<void> {
// Remove all listeners to stop new events
this.emitter.removeAllListeners();
// If you use DrainingEventEmitter, await drain here
}
}
A few design decisions in this bus that matter:
Events are plain objects with a type string, not class instances. Classes couple subscribers to a specific import. A plain object with a type field can cross module boundaries, serialization boundaries (if you ever queue events to a message broker), and process boundaries without ceremony.
subscribe returns an unsubscribe function. This lets the caller manage the listener lifecycle without holding a reference to the bus. React components, request handlers, and test fixtures can all call the returned function in their cleanup phase.
The wildcard '*' event enables monitoring and logging. A subscriber that catches all events can implement metrics, audit logging, or dead-letter detection without knowing about every event type at compile time.
The bus uses SafeEventEmitter internally. Async handler errors become 'error' events. You must subscribe to 'error' on the bus itself:
bus.subscribe('error', (err) => {
logger.error({ err }, 'Event bus error');
// Decide: crash, retry, or dead-letter
});
Without this, an async handler failure in any subscriber could crash the process. With it, you have a central place to decide the failure policy.
Pattern 7: EventEmitter vs. EventTarget
Node.js has two event APIs: the classic EventEmitter (Node’s own API, in events module) and the web-standard EventTarget (available since Node 18, global in Node 22+). They are not interchangeable.
EventTarget uses addEventListener, removeEventListener, and dispatchEvent. It passes a single Event object to listeners. It has once options and AbortSignal support built in. It is the API the browser uses, so if you are building something that runs in both environments (a shared library, a web worker, a service worker), EventTarget is the right choice.
EventEmitter is faster, supports multiple arguments, and has richer APIs (listeners(), rawListeners(), eventNames(), setMaxListeners()). If you are building a pure Node.js service, EventEmitter is usually the better choice.
If you need both (an event bus that works in the browser and on the server), wrap EventTarget:
class CrossPlatformEventBus {
private target = new EventTarget();
on(type: string, handler: EventListener): void {
this.target.addEventListener(type, handler);
}
off(type: string, handler: EventListener): void {
this.target.removeEventListener(type, handler);
}
emit(type: string, detail?: unknown): void {
this.target.dispatchEvent(new CustomEvent(type, { detail }));
}
}
This loses the multi-argument flexibility of EventEmitter. If you need multiple arguments, pack them into the detail object. It gains the ability to run in any JavaScript environment without a polyfill.
The checklist for shipping EventEmitter code
Before you merge that PR that adds an EventEmitter pattern to production, verify these:
- Async listeners are caught. Every listener that returns a promise either has its rejection handled or is used with an emitter that captures rejections (
captureRejections: trueor aSafeEventEmitterwrapper). - The
'error'event has a listener. If the emitter can emit errors, there is a listener that handles them. Without this, the process may crash. - Listeners are cleaned up. Every
on()call on a short-lived scope has a matchingoff()call. If the scope is a request, the cleanup happens on response close. If it is a test, the cleanup happens in teardown. maxListenersis set explicitly. If you know a particular emitter will have more than 10 listeners, callsetMaxListeners(n)with a deliberate number. The warning exists to catch leaks, not to annoy you. Suppress it only by raising the limit, not by silencing the warning.- The emitter type is explicit. If you use TypeScript, wrap EventEmitter in a typed interface. Raw EventEmitter with
anyarguments is a bug farm. - Shutdown drains events. If you use
emitAsyncor a similar pattern, the shutdown sequence drains in-flight handlers before exiting.
The working code
Here is the complete, copy-pasteable typed event emitter with async safety:
// safe-emitter.ts
import { EventEmitter } from 'events';
type EventMap = Record<string, unknown[]>;
export class TypedSafeEmitter<T extends EventMap> extends EventEmitter {
constructor(options?: { captureRejections?: boolean }) {
super({ captureRejections: options?.captureRejections ?? true });
}
emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
const listeners = this.rawListeners(event as string);
const results: Array<Promise<unknown>> = [];
for (const listener of listeners) {
try {
const result = (listener as (...args: T[K]) => unknown)(...args);
if (result instanceof Promise) {
results.push(result);
}
} catch (err) {
this.emit('error' as keyof T, err as T['error']);
}
}
for (const promise of results) {
promise.catch((err: Error) => {
this.emit('error' as keyof T, err as T['error']);
});
}
return true;
}
on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.on(event as string, listener as (...args: unknown[]) => void);
}
once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.once(event as string, listener as (...args: unknown[]) => void);
}
off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.off(event as string, listener as (...args: unknown[]) => void);
}
removeListener<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
return super.removeListener(event as string, listener as (...args: unknown[]) => void);
}
}
Usage:
interface AppEvents {
'user.registered': [userId: string, email: string];
'order.placed': [orderId: string, total: number];
'error': [error: Error];
}
const bus = new TypedSafeEmitter<AppEvents>();
bus.on('error', (err) => {
console.error('Bus error:', err.message);
});
bus.on('user.registered', async (userId, email) => {
await sendWelcomeEmail(email);
await createUserDirectory(userId);
});
bus.emit('user.registered', 'usr_456', 'newuser@example.com');
That is the entire pattern. One file, no dependencies, and every async error is caught, every event is typed, and the process does not crash when a listener fails.
When not to use EventEmitter
EventEmitter is not always the right tool. Three cases where you should reach for something else:
Case 1: You need exactly-once delivery. EventEmitter delivers to every listener, or to no listeners (if the emit happens before any listener is registered). There is no persistence, no replay, no acknowledgment. If you need guaranteed delivery, use a message queue (Redis Streams, Kafka, RabbitMQ). EventEmitter is a fire-and-forget in-process bus.
Case 2: You need backpressure. If a slow listener blocks the event loop, all other listeners are delayed. EventEmitter does not have backpressure. If your listeners perform I/O, the emit loop runs through all of them synchronously, starting each async operation, but none of them await. If the system is under load, the event loop fills with microtasks and latency degrades. For this scenario, consider a stream or a queue with concurrency control.
Case 3: You need cross-process events. EventEmitter is in-process only. For distributed events, use a message broker or a pub/sub system like Redis Pub/Sub, NATS, or Kafka. The domain event bus pattern above can be adapted to publish to and consume from a broker, but the EventEmitter itself stays in-process.
The takeaway
EventEmitter is the backbone of Node.js async, and it works remarkably well for a 15-year-old API. The bugs are not in the API itself. They are in how we use it: async listeners with abandoned promises, missing error listeners, leaked listeners on global emitters, and the assumption that emit() is fire-and-forget when the listeners are doing real work.
The patterns here fix those bugs with a typed wrapper, a safe emit override, explicit cleanup, and a drain mechanism for shutdown. They add up to about 60 lines of TypeScript that you can drop into any project and immediately eliminate the most common EventEmitter production incidents.
Every Node.js team hits these bugs eventually. The teams that fix them early do not have to debug phantom unhandled rejections at 3 AM.
A note from Yojji
The patterns in this post (typed event emitters, async-safe error propagation, graceful drain on shutdown, and proper listener lifecycle management) are the kind of infrastructure hygiene that Yojji builds into every Node.js backend they ship. Getting these details wrong is how a service that works in development quietly falls over under production load.
Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and building distributed systems where event-driven architecture and reliable async patterns are foundational. Since 2016, they have delivered full-cycle product engagements covering discovery, design, development, QA, and DevOps for clients across industries.