Node.js EventEmitter Pattern: Event-Driven Architecture Inside a Process
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
Node.js EventEmitter Pattern: Event-Driven Architecture Inside a Process
You're building an order processing service. When an order is placed, you need to: send a confirmation email, update inventory, trigger a loyalty points calculation, and notify the warehouse. The naive approach is calling all of that directly from your placeOrder function.
Three months later, placeOrder is 200 lines long, it imports from six modules, and adding a new side effect means editing the core business logic. You've accidentally built a monolith inside your function.
The EventEmitter pattern is how you undo this. Components emit events when something happens; other components listen and react. Nobody calls anybody. The order service doesn't know about email or inventory — it just says "an order was placed" and moves on.
What EventEmitter Is and Isn't
Node.js ships a built-in EventEmitter class in the events module. It's a synchronous, in-process pub/sub bus. That last part matters: listeners run in the same thread as the emitter, in the same process, in registration order, before the next line after emit() runs.
This is different from Kafka, RabbitMQ, or Redis Pub/Sub. Those are distributed message brokers with persistence, at-least-once delivery, and cross-process communication. EventEmitter is none of those things. It's a coordination primitive — like a function call, but decoupled.
Use it when components in the same process need to react to the same event without being directly coupled. Reach for a message broker when you need durability, fan-out to separate processes, or async delivery guarantees.
The Tightly Coupled Version
Here's the pattern you're almost certainly familiar with:
// ❌ placeOrder knows too much
async function placeOrder(order: Order): Promise<void> {
await db.orders.create(order);
// All side effects wired directly in the core flow
await emailService.sendConfirmation(order.userId, order.id);
await inventoryService.deduct(order.items);
await loyaltyService.addPoints(order.userId, order.total);
await warehouseService.queue(order);
}This works until it doesn't. The email service goes down? Your whole order placement fails. You want to add push notifications? Edit placeOrder. You want to disable loyalty points for B2B orders? Add a conditional to placeOrder. The function becomes the place where business rules accumulate forever.
The EventEmitter Version
// ✅ placeOrder emits an event, nothing more
import { EventEmitter } from 'node:events';
export const orderBus = new EventEmitter();
async function placeOrder(order: Order): Promise<void> {
await db.orders.create(order);
orderBus.emit('order:placed', order);
}
// In email.service.ts
orderBus.on('order:placed', async (order: Order) => {
await emailService.sendConfirmation(order.userId, order.id);
});
// In inventory.service.ts
orderBus.on('order:placed', async (order: Order) => {
await inventoryService.deduct(order.items);
});
// In loyalty.service.ts
orderBus.on('order:placed', async (order: Order) => {
await loyaltyService.addPoints(order.userId, order.total);
});placeOrder now has a single dependency: the event bus. Adding a new side effect is adding a new on() call — no changes to core logic. Removing one is deleting an on() call. The services don't know about each other.
Type-Safe EventEmitter in TypeScript
The raw EventEmitter accepts any string event name and any arguments, which means typos and signature mismatches are runtime errors. EventEmitter takes a generic EventMap parameter that makes all of this compile-time safe:
import { EventEmitter } from 'node:events';
interface Order {
id: string;
userId: string;
items: Array<{ productId: string; quantity: number }>;
total: number;
}
interface PaymentFailedEvent {
orderId: string;
reason: string;
retryable: boolean;
}
// Define your event map — event name → tuple of listener args
interface OrderEvents {
'order:placed': [order: Order];
'order:shipped': [orderId: string, trackingCode: string];
'payment:failed': [event: PaymentFailedEvent];
'order:cancelled': [orderId: string, reason: string];
// Declare 'error' too. Nothing adds it for you, and you will need it.
'error': [err: Error];
}
// TypeScript now validates event names and argument types
export const orderBus = new EventEmitter<OrderEvents>();
// ✅ Type-safe listener — TypeScript knows order is Order
orderBus.on('order:placed', (order) => {
console.log(order.userId); // autocomplete works
});
// ✅ Type-safe emit
orderBus.emit('order:placed', {
id: 'ord_123',
userId: 'usr_456',
items: [{ productId: 'p1', quantity: 2 }],
total: 49.99,
});
// ❌ Compile error: 'order:delivered' is not in OrderEvents
orderBus.emit('order:delivered', 'ord_123');
// ❌ Compile error: second arg should be string, not number
orderBus.on('order:shipped', (orderId, trackingCode) => {
const num = trackingCode * 2; // Type error: string × number
});The 'error' entry is not decoration. EventEmitter<T> narrows on/emit to exactly the keys in T, so on an emitter typed with an event map that omits 'error', writing bus.on('error', (err) => ...) is a compile error — Argument of type '(err: any) => void' is not assignable to parameter of type 'never', plus an implicit-any on err. Which is awkward, because pairing captureRejections with an 'error' listener is exactly what the next section tells you to do. Verified against @types/node 20.19.35 and TypeScript 5 with strict.
One correction on where this comes from: the generic is an @types/node feature, not a runtime one. You'll see it described as requiring Node 22 — it doesn't. The code above compiles clean under @types/node 20.19.35, and it would compile under @types/node 24 while running on Node 18. What gates it is the version of your type definitions. If you're stuck on older definitions, extend EventEmitter and declare typed overloads:
import { EventEmitter } from 'node:events';
// Older Node.js compatible approach
export declare interface OrderBus {
on(event: 'order:placed', listener: (order: Order) => void): this;
on(event: 'payment:failed', listener: (e: PaymentFailedEvent) => void): this;
emit(event: 'order:placed', order: Order): boolean;
emit(event: 'payment:failed', e: PaymentFailedEvent): boolean;
}
export class OrderBus extends EventEmitter {}
export const orderBus = new OrderBus();Verbose, but gives the same compile-time guarantees. Worth it.
How the Pattern Flows
The synchronous execution model is a double-edged sword. You get predictable ordering and no need for coordination. But a slow listener blocks everything behind it, and a throwing listener can crash the emitter unless you've set up captureRejections.
The Async Listener Trap
This is the most common production bug with EventEmitter:
// ❌ async listeners silently eat errors
orderBus.on('order:placed', async (order) => {
await emailService.send(order); // If this throws, nobody knows
});When an async listener throws, EventEmitter has already moved on. The rejection is unhandled. In production, this silently swallows errors.
Fix it with captureRejections:
// ✅ Rejections from async listeners route to the 'error' event
const orderBus = new EventEmitter<OrderEvents>({ captureRejections: true });
orderBus.on('error', (err) => {
logger.error('OrderBus listener error', err);
// Handle it — don't let it go to process.uncaughtRejection
});
orderBus.on('order:placed', async (order) => {
await emailService.send(order); // Throws → caught → 'error' event fires
});Always pair captureRejections: true with an 'error' listener — and remember 'error' has to be in the event map for the emitter to typecheck.
An unhandled 'error' event does crash the process, but not silently, and it's worth knowing what you'll actually see. e.emit('error', new Error('nobody is listening')) with no listener exits 1 and prints two stacks — where the Error was constructed, and where it was emitted:
Error: nobody is listening
at file:///app/order-bus.js:3:17
...
Emitted 'error' event at:
at file:///app/order-bus.js:3:3
...That second stack is the useful one and you only get it from an unhandled emitter error. So the failure mode isn't "no diagnostics", it's "the process is gone" — which in a request-serving app means every in-flight request dies with it. That's the reason to attach the listener.
Extending EventEmitter for Domain Objects
Instead of a singleton bus, you can make domain objects emit their own events. This is the pattern used by Node.js core internals (http.IncomingMessage, stream.Readable, etc.):
import { EventEmitter } from 'node:events';
interface OrderManagerEvents {
'placed': [order: Order];
'cancelled': [orderId: string];
'fulfilled': [orderId: string];
'error': [err: Error];
}
class OrderManager extends EventEmitter<OrderManagerEvents> {
private orders = new Map<string, Order>();
async place(order: Order): Promise<void> {
await db.orders.create(order);
this.orders.set(order.id, order);
this.emit('placed', order);
}
async cancel(orderId: string): Promise<void> {
await db.orders.cancel(orderId);
this.orders.delete(orderId);
this.emit('cancelled', orderId);
}
async fulfill(orderId: string): Promise<void> {
await db.orders.markFulfilled(orderId);
this.emit('fulfilled', orderId);
}
}
const orderManager = new OrderManager({ captureRejections: true });
orderManager.on('error', (err) => logger.error(err));
// External modules subscribe to the manager
orderManager.on('placed', async (order) => {
await warehouseService.queue(order);
});This makes the event API part of OrderManager's public interface rather than routing everything through a separate bus. Clean to test, clean to document, and the emitter's lifecycle is tied to the object.
One-Time Listeners and Awaiting Events
once() registers a listener that removes itself after the first invocation — useful for setup confirmation or handshakes:
// Wait for the first successful order, then stop listening
orderManager.once('placed', (order) => {
analytics.track('first_order', { orderId: order.id });
});The events.once() utility converts a single event into a promise, which works well with async/await flows:
import { once } from 'node:events';
async function waitForOrderConfirmation(bus: EventEmitter, orderId: string): Promise<Order> {
// Reject cleanly if waiting is cancelled
const ac = new AbortController();
const timeout = setTimeout(() => ac.abort(), 5000);
try {
const [order] = await once(bus, 'order:placed', { signal: ac.signal });
return order;
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new Error(`Order confirmation timed out for ${orderId}`);
}
throw err;
} finally {
clearTimeout(timeout);
}
}For a continuous stream of events, events.on() returns an async iterator:
import { on } from 'node:events';
async function processOrderStream(bus: EventEmitter): Promise<void> {
for await (const [order] of on(bus, 'order:placed')) {
await processOrder(order);
// Loops indefinitely until an 'error' event fires
}
}The Memory Leak You'll Hit
EventEmitter logs a warning when more than 10 listeners are attached to a single event:
(node:5) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 order:placed listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)That second line is the one to act on. node --trace-warnings prints the stack where the eleventh listener was attached, which is usually enough to identify the offender immediately.
This is not a hard limit — it's a diagnostic. It fires when you're probably adding listeners in a loop or inside request handlers without removing them. The fix is almost never setMaxListeners(Infinity) — it's finding and removing the leaked listeners.
// ❌ Adding a new listener on every request
app.post('/api/orders', async (req, res) => {
const order = req.body;
await placeOrder(order);
// This adds a new listener for every request — never removed
orderBus.on('order:placed', (o) => {
if (o.id === order.id) res.json({ success: true });
});
});
// ✅ Use once() and clean up, or redesign the flow
app.post('/api/orders', async (req, res) => {
const order = await placeOrder(req.body);
// Placeorder returns the saved order — just respond directly
res.json({ success: true, order });
});The warning is telling you something real. Treat it as a code smell, not a noise to suppress.
Listener Architecture
The key structural rule: register listeners at startup (in your app bootstrap or module initialization), not at request time. Listeners that are added and never removed are memory leaks. Listeners registered once at startup run for the life of the process — that's the intended usage.
When NOT to Use EventEmitter
Cross-process communication. EventEmitter is in-process only. If your services run in separate containers or workers, you need a message broker.
When you need delivery guarantees. If an event fires while a listener is down, it's gone. No replay, no dead-letter queue. For "process this payment exactly once" semantics, use a real queue.
Workflows where order of listener execution matters and must be enforced. Listeners fire in registration order, but that's a fragile contract. If step B must always run after step A, model it as a sequential function call, not two listeners on the same event.
High-throughput, CPU-bound event processing. Every listener runs synchronously before emit() returns. A slow listener is a blocked event loop. For CPU-heavy event processing, route work to worker threads instead of processing inline in listeners.
When the calling code needs a return value from the handler. Listeners can't return values to the emitter. If the caller needs to know the result of what the listener did, EventEmitter is the wrong abstraction — just call the function.
The pattern shines for in-process, fire-and-forget side effects: audit logging, cache invalidation, metrics tracking, secondary notifications. That's the sweet spot. Outside of that, think hard before reaching for it.
Getting Started with a Typed Bus
A minimal, production-ready setup:
// src/bus/order-bus.ts
import { EventEmitter } from 'node:events';
import type { Order } from '@/types/order';
export interface OrderBusEvents {
'order:placed': [order: Order];
'order:cancelled': [orderId: string, reason: string];
'order:shipped': [orderId: string, trackingCode: string];
'error': [err: Error];
}
class OrderBus extends EventEmitter<OrderBusEvents> {
constructor() {
super({ captureRejections: true });
this.on('error', (err) => {
// Your logger here — never let error go unhandled
console.error('[OrderBus] Listener error:', err);
});
}
}
export const orderBus = new OrderBus();
// src/bootstrap.ts — register all listeners once, at startup
import { orderBus } from './bus/order-bus';
import { emailService } from './services/email';
import { inventoryService } from './services/inventory';
orderBus.on('order:placed', async (order) => {
await emailService.sendConfirmation(order.userId, order.id);
});
orderBus.on('order:placed', async (order) => {
await inventoryService.deduct(order.items);
});The bus is a singleton. Listeners are registered in a single bootstrap file so you can see all side effects in one place. The core logic emits events and knows nothing about what listens.
That's the pattern. It won't replace Kafka for distributed systems, but for keeping your service's internals from turning into a ball of imports, it's one of the most underused tools in the standard library.
Comments (0)
No comments yet. Be the first to share your thoughts!