Skip to main content

Event-Driven Architecture: Building Reactive Systems in Node.js

Imagine you're waiting for an important package delivery. You have two options: either stand by your door all day checking every few minutes if the package has arrived (exhausting and inefficient), or simply wait for the doorbell to ring and respond when it happens (smart and efficient).

This is exactly the difference between traditional polling-based systems and event-driven architecture. Instead of constantly checking "Has something happened yet? How about now? Now?", your application simply waits and responds when events occur.

Event-Driven Architecture (EDA) is the design philosophy that makes Node.js incredibly efficient. It's how a single-threaded Node.js server can handle thousands of concurrent users, and it's the pattern behind real-time applications like chat systems, live dashboards, and multiplayer games.

Let's explore how this works and why it's a game-changer for modern applications.

What You Need to Know First

To get the most out of this guide, you should understand:

  • JavaScript/TypeScript fundamentals: Variables, functions, callbacks, and basic syntax
  • Asynchronous programming basics: What async operations are and why they exist
  • Node.js fundamentals: How to run Node.js code and basic imports

If you're completely new to asynchronous programming, we recommend reading about Asynchronous I/O in Node.js first, as event-driven architecture builds on those concepts.

What We'll Cover in This Article

By the end of this guide, you'll understand:

  • What event-driven architecture is and why it matters
  • How events replace constant checking (polling)
  • The EventEmitter class in Node.js and how to use it
  • How to create, emit, and listen to custom events
  • Managing multiple listeners and removing them when needed
  • Real-world use cases for event-driven patterns

What We'll Explain Along the Way

We'll introduce these concepts with full explanations:

  • Events vs polling (with real-world comparisons)
  • Publisher-subscriber pattern (how components communicate)
  • Event listeners and callbacks (what they are and how they work)
  • Memory management with events (preventing leaks)

What Is Event-Driven Architecture?

Event-Driven Architecture (EDA) is a software design pattern where components communicate by producing and responding to events rather than continuously checking for changes.

The Core Concept: React, Don't Check

An event is simply a notification that something has happened:

  • A user clicked a button
  • A file finished uploading
  • New data arrived from a database
  • A timer finished counting down
  • A network connection was established

Instead of components constantly asking "Did it happen yet? Did it happen yet?", they simply register interest in specific events and get notified automatically when those events occur.

Real-World Analogy: The Chat Group

Let's use a familiar example to understand how events work.

Scenario: A group chat with 50 members

❌ Without Event-Driven Architecture (Polling):

// Every member constantly checks for new messages
function checkForNewMessages(): void {
setInterval(() => {
// Each of 50 members asks the server every second
const hasNewMessage = askServer("Are there new messages?");

if (hasNewMessage) {
console.log("New message found! Downloading...");
const message = downloadMessage();
displayMessage(message);
}
}, 1000); // Check every second
}

// Problems:
// - 50 members × 60 checks/minute × 60 minutes = 180,000 requests per hour!
// - Server overwhelmed with "Are there new messages?" requests
// - Delays: Only find new messages when next check happens
// - Wasted bandwidth: 99% of checks return "no new messages"
// - Battery drain on mobile devices

✅ With Event-Driven Architecture:

// Server notifies all members when a message arrives
class ChatServer extends EventEmitter {
sendMessage(message: string, sender: string): void {
// Store the message
this.saveMessage(message);

// Emit an event - notify everyone automatically
this.emit("newMessage", { sender, message, timestamp: Date.now() });
}
}

// Each member registers one listener
chatServer.on("newMessage", (data) => {
console.log(`${data.sender}: ${data.message}`);
displayMessage(data);
});

// Benefits:
// - No constant checking - members wait for notifications
// - Instant delivery - no delays
// - Minimal server load - only 1 notification per actual message
// - Efficient - events only fire when something actually happens

The difference:

  • Polling approach: 180,000 requests per hour for a quiet chat
  • Event-driven approach: Only sends notifications when messages actually arrive (maybe 100 messages per hour)

That's a 1,800x reduction in unnecessary network requests!

Why Event-Driven Architecture Matters

1. Real-Time Responsiveness

Events happen instantly—no waiting for the next polling interval:

// Polling: Check every 5 seconds
setInterval(() => {
checkForUpdates(); // User might wait up to 5 seconds for updates
}, 5000);

// Event-driven: Instant notification
system.on("update", () => {
handleUpdate(); // User gets update immediately
});

2. Resource Efficiency

You only use resources when something actually happens:

// Polling: Constantly consuming CPU/bandwidth
while (true) {
checkStatus(); // Runs even when nothing is happening
sleep(1000);
}

// Event-driven: Idle until needed
system.on("statusChange", handleStatusChange); // Only runs when status changes

3. Loose Coupling

Components don't need to know about each other—they just emit and listen for events:

// Tight coupling: Components directly call each other
class UserService {
createUser(data: UserData): void {
const user = this.saveUser(data);
emailService.sendWelcomeEmail(user); // Must know about EmailService
analyticsService.trackSignup(user); // Must know about AnalyticsService
notificationService.notifyAdmins(user); // Must know about NotificationService
}
}

// Loose coupling: Components communicate via events
class UserService extends EventEmitter {
createUser(data: UserData): void {
const user = this.saveUser(data);
this.emit("userCreated", user); // Just emit an event
}
}

// Other services listen independently
userService.on("userCreated", (user) => emailService.sendWelcomeEmail(user));
userService.on("userCreated", (user) => analyticsService.trackSignup(user));
userService.on("userCreated", (user) => notificationService.notifyAdmins(user));

// Adding new features doesn't require changing UserService
userService.on("userCreated", (user) => rewardService.giveWelcomeBonus(user));

4. Scalability

Event-driven systems can handle many concurrent operations without blocking:

// Synchronous: Handle one at a time
requests.forEach((request) => {
processRequest(request); // Must finish before next one starts
});

// Event-driven: Handle all simultaneously
requests.forEach((request) => {
requestProcessor.emit("newRequest", request); // All emit immediately
});

// Multiple handlers can process events concurrently
requestProcessor.on("newRequest", handleRequest);
requestProcessor.on("newRequest", logRequest);
requestProcessor.on("newRequest", trackMetrics);

EventEmitter in Node.js: The Foundation

Node.js provides the EventEmitter class as the core building block for event-driven programming. It's a simple but powerful tool that lets you create objects that can emit events and register listeners for those events.

Your First EventEmitter: A Simple Example

Let's start with a basic example to see how EventEmitter works:

import { EventEmitter } from "events";

// Step 1: Create an instance of EventEmitter
const eventEmitter = new EventEmitter();

// Step 2: Register a listener for the 'greet' event
eventEmitter.on("greet", (name: string) => {
console.log(`Hello, ${name}!`);
});

// Step 3: Emit the 'greet' event with data
eventEmitter.emit("greet", "Alice");

// Output: Hello, Alice!

Understanding Each Step

Step 1: Creating an EventEmitter

const eventEmitter = new EventEmitter();

This creates an object that can:

  • Emit events: Notify listeners that something happened
  • Register listeners: Functions that run when specific events occur
  • Manage listeners: Add, remove, and track event handlers

Step 2: Registering a Listener

eventEmitter.on("greet", (name: string) => {
console.log(`Hello, ${name}!`);
});

Here's what this means:

  • on("greet", ...): "When someone emits a 'greet' event, call my function"
  • (name: string) => {...}: The callback function that runs when the event fires
  • name: Data passed from the emitter to the listener

Think of it like subscribing to a YouTube channel. You say "notify me when this channel posts," and the callback function is what you do when you get that notification.

Step 3: Emitting an Event

eventEmitter.emit("greet", "Alice");

This triggers the event:

  • emit("greet", ...): "Hey, a 'greet' event just happened!"
  • "Alice": Data to pass to all listeners (you can pass multiple arguments)

All functions listening for "greet" events will be called with "Alice" as their argument.

A More Realistic Example: File Upload Progress

Let's see a practical use case—tracking file upload progress:

import { EventEmitter } from "events";
import fs from "fs";

class FileUploader extends EventEmitter {
uploadFile(filePath: string): void {
// Emit event: Upload started
this.emit("uploadStarted", { filePath, timestamp: Date.now() });

// Simulate file reading in chunks
const fileStream = fs.createReadStream(filePath);
let uploadedBytes = 0;
const totalBytes = fs.statSync(filePath).size;

fileStream.on("data", (chunk: Buffer) => {
uploadedBytes += chunk.length;
const progress = (uploadedBytes / totalBytes) * 100;

// Emit event: Progress update
this.emit("uploadProgress", {
filePath,
uploadedBytes,
totalBytes,
progress: Math.round(progress),
});
});

fileStream.on("end", () => {
// Emit event: Upload completed
this.emit("uploadComplete", {
filePath,
totalBytes,
timestamp: Date.now(),
});
});

fileStream.on("error", (error: Error) => {
// Emit event: Upload failed
this.emit("uploadError", { filePath, error: error.message });
});
}
}

// Create uploader instance
const uploader = new FileUploader();

// Register listeners for different events
uploader.on("uploadStarted", (data) => {
console.log(`📤 Starting upload: ${data.filePath}`);
});

uploader.on("uploadProgress", (data) => {
console.log(
`📊 Progress: ${data.progress}% (${data.uploadedBytes}/${data.totalBytes} bytes)`
);
});

uploader.on("uploadComplete", (data) => {
console.log(
`✅ Upload complete: ${data.filePath} (${data.totalBytes} bytes)`
);
});

uploader.on("uploadError", (data) => {
console.error(`❌ Upload failed: ${data.filePath} - ${data.error}`);
});

// Start upload
uploader.uploadFile("large-video.mp4");

// Output:
// 📤 Starting upload: large-video.mp4
// 📊 Progress: 10% (10485760/104857600 bytes)
// 📊 Progress: 20% (20971520/104857600 bytes)
// 📊 Progress: 30% (31457280/104857600 bytes)
// ...
// 📊 Progress: 100% (104857600/104857600 bytes)
// ✅ Upload complete: large-video.mp4 (104857600 bytes)

Why This Pattern Is Powerful

Notice how clean the separation is:

  1. FileUploader class: Only responsible for uploading—emits events at key moments
  2. Event listeners: Handle UI updates, logging, analytics separately
  3. No tight coupling: FileUploader doesn't know or care who's listening

You could add more listeners without touching the FileUploader class:

// Add analytics tracking
uploader.on("uploadComplete", (data) => {
analytics.track("File Uploaded", {
size: data.totalBytes,
timestamp: data.timestamp,
});
});

// Add notification
uploader.on("uploadComplete", (data) => {
notificationService.notify("Upload finished!", data.filePath);
});

// Add error logging
uploader.on("uploadError", (data) => {
logger.error("Upload failed", { file: data.filePath, error: data.error });
});

Working with Multiple Listeners

One of EventEmitter's most powerful features is that multiple functions can listen to the same event. They all get called in the order they were registered.

Registering Multiple Listeners

import { EventEmitter } from "events";

const emitter = new EventEmitter();

// First listener
emitter.on("userLoggedIn", (username: string) => {
console.log(`1. Welcome back, ${username}!`);
});

// Second listener
emitter.on("userLoggedIn", (username: string) => {
console.log(`2. Logging activity for ${username}`);
});

// Third listener
emitter.on("userLoggedIn", (username: string) => {
console.log(`3. Updating ${username}'s last login time`);
});

// Emit the event once - all three listeners fire in order
emitter.emit("userLoggedIn", "Alice");

// Output:
// 1. Welcome back, Alice!
// 2. Logging activity for Alice
// 3. Updating Alice's last login time

Key points:

  • Execution order: Listeners run in the order they were registered (FIFO - First In, First Out)
  • Synchronous execution: Each listener completes before the next one starts
  • Shared data: All listeners receive the same arguments

How EventEmitter Stores Listeners Internally

When you register listeners, EventEmitter stores them in an internal _events object. Understanding this structure helps you debug and optimize your code.

With a single listener:

const emitter = new EventEmitter();

emitter.on("greet", (name: string) => {
console.log(`Hello, ${name}!`);
});

// Internal structure:
console.log(emitter);
/*
{
_events: {
greet: [Function: bound greet]
},
_eventsCount: 1,
_maxListeners: undefined
}
*/

With multiple listeners:

const emitter = new EventEmitter();

emitter.on("greet", (name: string) => {
console.log(`Hello, ${name}!`);
});

emitter.on("greet", (name: string) => {
console.log(`Welcome, ${name}!`);
});

emitter.on("greet", (name: string) => {
console.log(`Greetings, ${name}!`);
});

// Internal structure:
console.log(emitter);
/*
{
_events: {
greet: [
[Function: bound greet],
[Function: bound greet],
[Function: bound greet]
]
},
_eventsCount: 1,
_maxListeners: undefined
}
*/

What each property means:

PropertyPurposeExample Value
_eventsObject storing all events and their listener functions{ greet: [Function] }
_eventsCountNumber of different event types being listened to1 (only "greet")
_maxListenersMaximum listeners allowed per event (default: 10, undefined = use default)undefined

Why this matters:

  • Memory debugging: You can inspect _events to see what listeners are registered
  • Leak detection: If _events keeps growing, you might have a memory leak
  • Performance: Each event name is a key, and multiple listeners form an array

Removing Listeners: Cleaning Up After Yourself

Listeners stay registered forever unless you remove them. This can cause memory leaks if you're not careful.

Problem: Memory leak with unremoved listeners

const emitter = new EventEmitter();

// BAD: Creates a new listener every second
setInterval(() => {
emitter.on("tick", () => {
console.log("Tick!");
});
}, 1000);

// After 1 minute: 60 listeners registered (all doing the same thing!)
// After 1 hour: 3,600 listeners registered (severe memory leak!)

Solution: Remove listeners when done

Method 1: Using off() or removeListener()

const emitter = new EventEmitter();

// Store the listener function in a variable so you can remove it later
const greetListener = (name: string): void => {
console.log(`Hello, ${name}!`);
};

// Register listener
emitter.on("greet", greetListener);

// Use it a few times
emitter.emit("greet", "Alice"); // Hello, Alice!
emitter.emit("greet", "Bob"); // Hello, Bob!

// Remove the listener
emitter.off("greet", greetListener);
// or: emitter.removeListener("greet", greetListener);

// This won't do anything now - listener is gone
emitter.emit("greet", "Charlie"); // (no output)

Important: You must pass the exact same function reference to remove it. This won't work:

// ❌ WRONG: Different function instance
emitter.on("greet", (name: string) => console.log(`Hello, ${name}!`));
emitter.off("greet", (name: string) => console.log(`Hello, ${name}!`)); // Doesn't remove anything!

// ✅ CORRECT: Same function reference
const greetFn = (name: string) => console.log(`Hello, ${name}!`);
emitter.on("greet", greetFn);
emitter.off("greet", greetFn); // Successfully removes listener

Method 2: Using once() for Auto-Cleanup

If you only want a listener to fire once, use once() instead of on():

const emitter = new EventEmitter();

// This listener automatically removes itself after first call
emitter.once("welcome", (name: string) => {
console.log(`Welcome to the app, ${name}!`);
});

emitter.emit("welcome", "Alice"); // Welcome to the app, Alice!
emitter.emit("welcome", "Bob"); // (no output - listener already removed)

Method 3: Remove All Listeners for an Event

const emitter = new EventEmitter();

emitter.on("data", () => console.log("Listener 1"));
emitter.on("data", () => console.log("Listener 2"));
emitter.on("data", () => console.log("Listener 3"));

console.log(emitter.listenerCount("data")); // 3

// Remove all listeners for "data" event
emitter.removeAllListeners("data");

console.log(emitter.listenerCount("data")); // 0

emitter.emit("data"); // (no output - all listeners removed)

Method 4: Remove ALL Listeners for ALL Events

const emitter = new EventEmitter();

emitter.on("event1", () => console.log("Event 1"));
emitter.on("event2", () => console.log("Event 2"));
emitter.on("event3", () => console.log("Event 3"));

// Nuclear option: remove everything
emitter.removeAllListeners();

emitter.emit("event1"); // (no output)
emitter.emit("event2"); // (no output)
emitter.emit("event3"); // (no output)

Practical Example: Temporary Listeners

Here's a real-world scenario where cleanup matters:

import { EventEmitter } from "events";

class WebSocketConnection extends EventEmitter {
connect(): void {
console.log("Connecting to server...");

// One-time listener: Only care about first connection
this.once("connected", () => {
console.log("✅ Successfully connected!");
});

// Simulate connection after 2 seconds
setTimeout(() => {
this.emit("connected");
}, 2000);
}

disconnect(): void {
// Clean up all listeners when disconnecting
this.removeAllListeners();
console.log("Disconnected and cleaned up listeners");
}
}

const ws = new WebSocketConnection();
ws.connect();

// After connection, disconnect
setTimeout(() => {
ws.disconnect();
}, 5000);

// Output:
// Connecting to server...
// ✅ Successfully connected! (after 2 seconds)
// Disconnected and cleaned up listeners (after 5 seconds)

Advanced EventEmitter Features

Passing Multiple Arguments

You can pass as many arguments as you need when emitting events:

const emitter = new EventEmitter();

emitter.on(
"transaction",
(userId: string, amount: number, currency: string, timestamp: Date) => {
console.log(
`User ${userId} paid ${amount} ${currency} at ${timestamp.toISOString()}`
);
}
);

emitter.emit("transaction", "user123", 49.99, "USD", new Date());
// Output: User user123 paid 49.99 USD at 2025-10-25T10:30:00.000Z

Passing Objects for Cleaner Code

For events with many parameters, pass an object instead:

interface TransactionEvent {
userId: string;
amount: number;
currency: string;
timestamp: Date;
paymentMethod: string;
orderId: string;
}

emitter.on("transaction", (data: TransactionEvent) => {
console.log(`Order ${data.orderId}: ${data.amount} ${data.currency}`);
});

emitter.emit("transaction", {
userId: "user123",
amount: 49.99,
currency: "USD",
timestamp: new Date(),
paymentMethod: "credit_card",
orderId: "ORD-12345",
});

Error Events: Special Handling

EventEmitter treats error events specially. If you emit an error event and no one is listening, Node.js throws an exception and crashes your application:

const emitter = new EventEmitter();

// ❌ DANGEROUS: No error listener registered
emitter.emit("error", new Error("Something went wrong!"));
// Node.js crashes: Uncaught Error: Something went wrong!

Always register an error listener:

const emitter = new EventEmitter();

// ✅ SAFE: Error listener registered
emitter.on("error", (error: Error) => {
console.error("Error occurred:", error.message);
// Handle error gracefully instead of crashing
});

emitter.emit("error", new Error("Something went wrong!"));
// Output: Error occurred: Something went wrong!
// Application continues running

Checking Listener Count

You can check how many listeners are registered for an event:

const emitter = new EventEmitter();

emitter.on("data", () => console.log("Listener 1"));
emitter.on("data", () => console.log("Listener 2"));
emitter.on("data", () => console.log("Listener 3"));

console.log(emitter.listenerCount("data")); // 3

// Get all event names with listeners
console.log(emitter.eventNames()); // ["data"]

Maximum Listeners Warning

By default, EventEmitter warns you if you register more than 10 listeners for a single event (possible memory leak):

const emitter = new EventEmitter();

// Register 11 listeners
for (let i = 0; i < 11; i++) {
emitter.on("data", () => console.log(`Listener ${i}`));
}

// Warning: Possible EventEmitter memory leak detected.
// 11 data listeners added. Use emitter.setMaxListeners() to increase limit

If you legitimately need more listeners:

const emitter = new EventEmitter();

// Increase the limit to 20
emitter.setMaxListeners(20);

// Or set to 0 for unlimited (use with caution!)
emitter.setMaxListeners(0);

Real-World Use Cases

Use Case 1: Real-Time Chat Application

import { EventEmitter } from "events";

class ChatRoom extends EventEmitter {
private messages: Array<{ user: string; message: string; timestamp: Date }> =
[];

sendMessage(user: string, message: string): void {
const msg = { user, message, timestamp: new Date() };
this.messages.push(msg);

// Emit event to all listeners (connected users)
this.emit("newMessage", msg);
}

getUserCount(): number {
// Count listeners = number of connected users
return this.listenerCount("newMessage");
}
}

const chatRoom = new ChatRoom();

// User 1 joins
chatRoom.on("newMessage", (msg) => {
console.log(`[User1 received] ${msg.user}: ${msg.message}`);
});

// User 2 joins
chatRoom.on("newMessage", (msg) => {
console.log(`[User2 received] ${msg.user}: ${msg.message}`);
});

console.log(`Users in room: ${chatRoom.getUserCount()}`); // 2

// Someone sends a message
chatRoom.sendMessage("Alice", "Hello everyone!");

// Output:
// Users in room: 2
// [User1 received] Alice: Hello everyone!
// [User2 received] Alice: Hello everyone!

Use Case 2: Build Pipeline with Status Updates

import { EventEmitter } from "events";

class BuildPipeline extends EventEmitter {
async runBuild(projectName: string): Promise<void> {
this.emit("buildStarted", { projectName, timestamp: Date.now() });

try {
// Step 1: Install dependencies
this.emit("stepStarted", { step: "install", projectName });
await this.installDependencies();
this.emit("stepCompleted", { step: "install", projectName });

// Step 2: Run tests
this.emit("stepStarted", { step: "test", projectName });
await this.runTests();
this.emit("stepCompleted", { step: "test", projectName });

// Step 3: Build
this.emit("stepStarted", { step: "build", projectName });
await this.buildProject();
this.emit("stepCompleted", { step: "build", projectName });

// Step 4: Deploy
this.emit("stepStarted", { step: "deploy", projectName });
await this.deployProject();
this.emit("stepCompleted", { step: "deploy", projectName });

this.emit("buildCompleted", { projectName, timestamp: Date.now() });
} catch (error) {
this.emit("buildFailed", { projectName, error: error.message });
}
}

private async installDependencies(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1000));
}

private async runTests(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 2000));
}

private async buildProject(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 3000));
}

private async deployProject(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1500));
}
}

const pipeline = new BuildPipeline();

// Logger listens to all events
pipeline.on("buildStarted", (data) =>
console.log(`🚀 Build started: ${data.projectName}`)
);
pipeline.on("stepStarted", (data) => console.log(`${data.step}...`));
pipeline.on("stepCompleted", (data) =>
console.log(`${data.step} complete`)
);
pipeline.on("buildCompleted", (data) =>
console.log(`🎉 Build completed: ${data.projectName}`)
);
pipeline.on("buildFailed", (data) =>
console.log(`❌ Build failed: ${data.error}`)
);

// Notification service listens to completion events
pipeline.on("buildCompleted", (data) => {
console.log(`📧 Sending success email for ${data.projectName}`);
});

pipeline.on("buildFailed", (data) => {
console.log(`📧 Sending failure email for ${data.projectName}`);
});

// Run the build
pipeline.runBuild("my-awesome-app");

// Output:
// 🚀 Build started: my-awesome-app
// ⏳ install...
// ✅ install complete
// ⏳ test...
// ✅ test complete
// ⏳ build...
// ✅ build complete
// ⏳ deploy...
// ✅ deploy complete
// 🎉 Build completed: my-awesome-app
// 📧 Sending success email for my-awesome-app

Use Case 3: Database Connection Pool Monitor

import { EventEmitter } from "events";

class DatabaseConnectionPool extends EventEmitter {
private activeConnections = 0;
private maxConnections = 10;

acquireConnection(): void {
if (this.activeConnections >= this.maxConnections) {
this.emit("poolExhausted", {
active: this.activeConnections,
max: this.maxConnections,
});
throw new Error("No available connections");
}

this.activeConnections++;
this.emit("connectionAcquired", { active: this.activeConnections });

if (this.activeConnections > this.maxConnections * 0.8) {
this.emit("poolWarning", {
message: "Connection pool at 80% capacity",
active: this.activeConnections,
max: this.maxConnections,
});
}
}

releaseConnection(): void {
if (this.activeConnections > 0) {
this.activeConnections--;
this.emit("connectionReleased", { active: this.activeConnections });
}
}
}

const pool = new DatabaseConnectionPool();

// Monitoring listeners
pool.on("poolWarning", (data) => {
console.warn(`⚠️ ${data.message} (${data.active}/${data.max})`);
});

pool.on("poolExhausted", (data) => {
console.error(`🚨 Connection pool exhausted! (${data.active}/${data.max})`);
// Alert operations team
});

pool.on("connectionAcquired", (data) => {
console.log(`📊 Active connections: ${data.active}`);
});

pool.on("connectionReleased", (data) => {
console.log(`📊 Active connections: ${data.active}`);
});

// Simulate connection usage
for (let i = 0; i < 9; i++) {
pool.acquireConnection();
}

// Output:
// 📊 Active connections: 1
// 📊 Active connections: 2
// ...
// 📊 Active connections: 8
// ⚠️ Connection pool at 80% capacity (8/10)
// 📊 Active connections: 9

Common Pitfalls and Solutions

Pitfall 1: Forgetting to Remove Listeners (Memory Leaks)

Problem:

// BAD: Creates new listener every time component mounts
class Dashboard extends EventEmitter {
initialize(): void {
dataService.on("dataUpdate", (data) => {
this.updateDisplay(data);
});
}
}

// If initialize() is called 100 times, you have 100 listeners!
const dashboard = new Dashboard();
dashboard.initialize(); // Listener 1
dashboard.initialize(); // Listener 2 (leak!)
dashboard.initialize(); // Listener 3 (leak!)

Solution:

// GOOD: Store listener reference and clean up
class Dashboard extends EventEmitter {
private dataListener?: (data: any) => void;

initialize(): void {
// Remove old listener if it exists
if (this.dataListener) {
dataService.off("dataUpdate", this.dataListener);
}

// Create new listener
this.dataListener = (data) => {
this.updateDisplay(data);
};

dataService.on("dataUpdate", this.dataListener);
}

cleanup(): void {
if (this.dataListener) {
dataService.off("dataUpdate", this.dataListener);
this.dataListener = undefined;
}
}
}

Pitfall 2: Not Handling Error Events

Problem:

// BAD: No error listener - app crashes on error
const emitter = new EventEmitter();

emitter.emit("error", new Error("Something broke!"));
// ❌ Uncaught Error: Something broke!
// Application crashes

Solution:

// GOOD: Always handle errors
const emitter = new EventEmitter();

emitter.on("error", (error: Error) => {
console.error("Error occurred:", error.message);
// Log to monitoring service
// Alert team
// Attempt recovery
});

emitter.emit("error", new Error("Something broke!"));
// ✅ Application continues running

Pitfall 3: Synchronous Listeners Blocking Event Loop

Problem:

// BAD: Slow synchronous listener blocks everything
emitter.on("data", (data) => {
// Simulate slow processing (blocking)
for (let i = 0; i < 1000000000; i++) {
// Heavy computation
}
console.log("Processed:", data);
});

// This emits will block for several seconds
emitter.emit("data", "chunk1"); // Blocks
emitter.emit("data", "chunk2"); // Blocks
emitter.emit("data", "chunk3"); // Blocks

Solution:

// GOOD: Use async listeners or offload to worker
emitter.on("data", async (data) => {
// Process asynchronously
await processDataAsync(data);
console.log("Processed:", data);
});

// Or use setImmediate to defer processing
emitter.on("data", (data) => {
setImmediate(() => {
processDataSync(data);
console.log("Processed:", data);
});
});

Pitfall 4: Emitting Events in Constructor

Problem:

// BAD: Emitting before listeners can be registered
class DataLoader extends EventEmitter {
constructor() {
super();
this.emit("loading"); // No one is listening yet!
this.loadData();
}
}

const loader = new DataLoader();
loader.on("loading", () => {
console.log("Loading..."); // Never fires!
});

Solution:

// GOOD: Emit after listeners can be registered
class DataLoader extends EventEmitter {
constructor() {
super();
}

start(): void {
this.emit("loading"); // Now listeners are registered
this.loadData();
}
}

const loader = new DataLoader();
loader.on("loading", () => {
console.log("Loading..."); // Fires correctly!
});
loader.start();

Pitfall 5: Modifying Data in Listeners

Problem:

// BAD: Listeners modify shared data object
const emitter = new EventEmitter();

const sharedData = { count: 0 };

emitter.on("update", (data) => {
data.count++; // Modifies original object
});

emitter.on("update", (data) => {
data.count++; // Also modifies original object
});

emitter.emit("update", sharedData);
console.log(sharedData.count); // 2 (unexpected side effects!)

Solution:

// GOOD: Pass copies or use immutable patterns
const emitter = new EventEmitter();

const sharedData = { count: 0 };

emitter.on("update", (data) => {
const copy = { ...data }; // Create copy
copy.count++;
console.log("Listener 1:", copy.count);
});

emitter.on("update", (data) => {
const copy = { ...data }; // Create copy
copy.count++;
console.log("Listener 2:", copy.count);
});

emitter.emit("update", sharedData);
console.log(sharedData.count); // Still 0 (no side effects)

EventEmitter vs Other Patterns

EventEmitter vs Callbacks

Callbacks:

// One-to-one communication
function fetchData(callback: (data: any) => void): void {
setTimeout(() => {
callback({ result: "data" });
}, 1000);
}

fetchData((data) => console.log(data));

EventEmitter:

// One-to-many communication
class DataFetcher extends EventEmitter {
fetchData(): void {
setTimeout(() => {
this.emit("data", { result: "data" });
}, 1000);
}
}

const fetcher = new DataFetcher();
fetcher.on("data", (data) => console.log("Listener 1:", data));
fetcher.on("data", (data) => console.log("Listener 2:", data));
fetcher.fetchData(); // Both listeners receive data

When to use each:

  • Callbacks: Single response to single caller (function returns value to caller)
  • EventEmitter: Multiple listeners need to know about events (broadcast to many)

EventEmitter vs Promises

Promises:

// Single future value
function loadFile(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve("file content"), 1000);
});
}

loadFile().then((content) => console.log(content));

EventEmitter:

// Multiple events over time
class FileLoader extends EventEmitter {
loadFile(): void {
this.emit("start");
setTimeout(() => this.emit("progress", 50), 500);
setTimeout(() => this.emit("progress", 100), 1000);
setTimeout(() => this.emit("complete", "file content"), 1000);
}
}

const loader = new FileLoader();
loader.on("start", () => console.log("Started"));
loader.on("progress", (p) => console.log(`Progress: ${p}%`));
loader.on("complete", (content) => console.log("Done:", content));
loader.loadFile();

When to use each:

  • Promises: Single async operation with one result (file load, API call)
  • EventEmitter: Ongoing events or progress updates (streaming, monitoring)

EventEmitter vs Observables (RxJS)

EventEmitter:

const emitter = new EventEmitter();

emitter.on("data", (x) => console.log(x));
emitter.emit("data", 1);
emitter.emit("data", 2);
emitter.emit("data", 3);

Observable:

import { Observable } from "rxjs";

const observable = new Observable((subscriber) => {
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
subscriber.complete();
});

observable.subscribe((x) => console.log(x));

When to use each:

  • EventEmitter: Simple pub-sub within Node.js, built-in, no dependencies
  • Observables: Complex async flows, operators (map, filter, merge), cancellation

Performance Considerations

How Many Listeners Is Too Many?

EventEmitter is optimized for typical use cases, but performance degrades with many listeners:

const emitter = new EventEmitter();

// Add 1000 listeners
for (let i = 0; i < 1000; i++) {
emitter.on("data", () => {
// Do something
});
}

// Emitting becomes slower with more listeners
console.time("emit");
emitter.emit("data", "test"); // Calls all 1000 functions
console.timeEnd("emit"); // ~5-10ms on modern hardware

Guidelines:

  • < 10 listeners: Excellent performance, no concerns
  • 10-50 listeners: Good performance, acceptable for most use cases
  • 50-200 listeners: Noticeable overhead, consider alternatives
  • > 200 listeners: Significant performance impact, refactor architecture

Optimizing Event-Heavy Applications

1. Use once() for single-use listeners:

// GOOD: Auto-cleanup
emitter.once("ready", handleReady);

// vs BAD: Manual cleanup
emitter.on("ready", function handler() {
handleReady();
emitter.off("ready", handler);
});

2. Debounce high-frequency events:

let timeout: NodeJS.Timeout;

emitter.on("input", (data) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
processInput(data); // Only process after 300ms of no events
}, 300);
});

3. Batch events instead of individual emissions:

// BAD: Emit for every item
items.forEach((item) => {
emitter.emit("itemProcessed", item);
});

// GOOD: Emit once with all items
emitter.emit("itemsProcessed", items);

Creating Custom Event Emitters

For real applications, extend EventEmitter to create custom classes:

import { EventEmitter } from "events";

// Define event types for TypeScript
interface ServerEvents {
started: () => void;
request: (url: string, method: string) => void;
response: (statusCode: number, duration: number) => void;
error: (error: Error) => void;
stopped: () => void;
}

class Server extends EventEmitter {
private isRunning = false;

// Type-safe event emission
emit<K extends keyof ServerEvents>(
event: K,
...args: Parameters<ServerEvents[K]>
): boolean {
return super.emit(event, ...args);
}

// Type-safe event listening
on<K extends keyof ServerEvents>(event: K, listener: ServerEvents[K]): this {
return super.on(event, listener);
}

start(): void {
if (this.isRunning) {
throw new Error("Server already running");
}

this.isRunning = true;
this.emit("started");
}

handleRequest(url: string, method: string): void {
const startTime = Date.now();
this.emit("request", url, method);

// Simulate request processing
setTimeout(() => {
const duration = Date.now() - startTime;
const statusCode = Math.random() > 0.1 ? 200 : 500;
this.emit("response", statusCode, duration);

if (statusCode === 500) {
this.emit("error", new Error(`Request failed: ${url}`));
}
}, Math.random() * 1000);
}

stop(): void {
if (!this.isRunning) {
throw new Error("Server not running");
}

this.isRunning = false;
this.emit("stopped");
}
}

// Usage with type safety
const server = new Server();

server.on("started", () => {
console.log("🚀 Server started");
});

server.on("request", (url, method) => {
console.log(`📥 ${method} ${url}`);
});

server.on("response", (statusCode, duration) => {
console.log(`📤 Response: ${statusCode} (${duration}ms)`);
});

server.on("error", (error) => {
console.error(`❌ Error: ${error.message}`);
});

server.on("stopped", () => {
console.log("🛑 Server stopped");
});

// Run server
server.start();
server.handleRequest("/api/users", "GET");
server.handleRequest("/api/posts", "POST");

setTimeout(() => {
server.stop();
}, 5000);

Summary: Key Takeaways

Let's recap what we've learned about event-driven architecture and EventEmitter:

Core Concepts:

  • Event-driven architecture eliminates constant polling by notifying components when events occur
  • EventEmitter is Node.js's built-in pub-sub implementation for event-driven programming
  • Events are notifications that something happened—they carry data to interested listeners
  • Listeners are callback functions that run when specific events are emitted

How EventEmitter Works:

  • Register listeners with .on(eventName, callback) to subscribe to events
  • Emit events with .emit(eventName, data) to notify all listeners
  • Multiple listeners can subscribe to the same event—they execute in registration order
  • Remove listeners with .off() or use .once() for auto-cleanup

Best Practices:

  • Always handle error events to prevent application crashes
  • Clean up listeners when done to avoid memory leaks
  • Use .once() for single-use listeners instead of manual cleanup
  • Pass objects instead of many parameters for cleaner code
  • Extend EventEmitter to create custom, type-safe event classes

Common Patterns:

  • Real-time updates: Chat apps, live dashboards, notifications
  • Progress tracking: File uploads, build pipelines, data processing
  • Resource monitoring: Connection pools, memory usage, system health
  • Loose coupling: Components communicate without direct dependencies

When to Use:

  • ✅ Multiple components need to know about the same events
  • ✅ Events happen over time (not just once)
  • ✅ You need real-time reactivity
  • ✅ You want loose coupling between components

When Not to Use:

  • ❌ Single async operation with one result (use Promises)
  • ❌ Simple function callbacks (use regular callbacks)
  • ❌ Complex reactive streams (consider RxJS Observables)

The Bottom Line:

Event-driven architecture and EventEmitter are fundamental to Node.js's efficiency. They enable real-time, reactive applications that scale gracefully without the overhead of constant polling or tight coupling. Master these patterns, and you'll build systems that are responsive, maintainable, and performant.

What's Next?

Now that you understand event-driven architecture and EventEmitter, explore these related topics:

  • Streams in Node.js: Built on EventEmitter for processing large data efficiently
  • Custom Events in the Browser: Using CustomEvent API for frontend event-driven patterns
  • Asynchronous Patterns: How events, callbacks, promises, and async/await work together
  • Pub-Sub Systems: Redis, Message Queues (RabbitMQ, Kafka) for distributed event-driven architectures
  • Reactive Programming: RxJS Observables for complex event flows and transformations
  • Memory Profiling: Detecting and fixing event listener memory leaks

Understanding event-driven architecture is crucial for building modern Node.js applications. With EventEmitter as your foundation, you're equipped to create responsive, scalable systems that react to events in real-time.