Event Emitters in Node.js
Understand the EventEmitter pattern in Node.js. Covers custom emitters, error handling, async events, memory leaks, and real-world patterns.
What you'll learn
- ✓How EventEmitter works under the hood
- ✓Creating custom event emitters
- ✓Error handling and the error event
- ✓Avoiding memory leaks with listener management
- ✓Real-world patterns: typed events, async events, once
Prerequisites
- •Node.js basics
- •Understanding of callbacks and promises
- •Basic OOP (classes)
The EventEmitter is the foundation of Node.js. Streams, HTTP servers, child processes, and most core modules are built on it. It implements the observer pattern: objects emit named events, and registered listener functions are called when those events fire.
Basic usage
const EventEmitter = require('events');
const emitter = new EventEmitter();
// Register a listener
emitter.on('greet', (name) => {
console.log(`Hello, ${name}!`);
});
// Emit the event
emitter.emit('greet', 'Alice');
// Output: Hello, Alice!
emit calls all registered listeners for the given event synchronously, in the order they were registered.
Core methods
const emitter = new EventEmitter();
// on: adds a listener (alias: addListener)
emitter.on('data', handler);
// once: adds a listener that fires only once
emitter.once('connect', () => {
console.log('Connected (this runs only once)');
});
// off: removes a listener (alias: removeListener)
emitter.off('data', handler);
// removeAllListeners: removes all listeners for an event
emitter.removeAllListeners('data');
// listenerCount: how many listeners for an event
console.log(emitter.listenerCount('data'));
// eventNames: list all events with listeners
console.log(emitter.eventNames());
// prependListener: add listener to the beginning of the array
emitter.prependListener('data', handler);
Creating a custom event emitter
Extend EventEmitter to create domain-specific event sources:
const EventEmitter = require('events');
class FileWatcher extends EventEmitter {
constructor(filePath) {
super();
this.filePath = filePath;
this.lastModified = null;
}
start(intervalMs = 1000) {
const fs = require('fs');
this.interval = setInterval(() => {
fs.stat(this.filePath, (err, stats) => {
if (err) {
this.emit('error', err);
return;
}
const mtime = stats.mtime.getTime();
if (this.lastModified && mtime !== this.lastModified) {
this.emit('change', {
file: this.filePath,
previous: new Date(this.lastModified),
current: stats.mtime,
});
}
this.lastModified = mtime;
});
}, intervalMs);
this.emit('start', this.filePath);
}
stop() {
clearInterval(this.interval);
this.emit('stop');
}
}
// Usage
const watcher = new FileWatcher('./config.json');
watcher.on('start', (file) => console.log(`Watching ${file}`));
watcher.on('change', (info) => console.log('File changed:', info));
watcher.on('error', (err) => console.error('Watch error:', err));
watcher.start(2000);
The error event
The error event is special. If an error event is emitted and no listener is registered for it, Node.js throws the error and crashes the process:
const emitter = new EventEmitter();
// This will crash the process with an unhandled error
emitter.emit('error', new Error('Something went wrong'));
Always register an error listener:
emitter.on('error', (err) => {
console.error('Handled error:', err.message);
});
Listener management and memory leaks
By default, an EventEmitter warns if more than 10 listeners are registered for a single event. This is usually a sign of a memory leak (forgetting to remove listeners):
// This will print a warning after 11 registrations
for (let i = 0; i < 20; i++) {
emitter.on('data', () => {});
}
// MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
If you genuinely need more listeners, set the limit explicitly:
emitter.setMaxListeners(100);
// Or for all emitters:
EventEmitter.defaultMaxListeners = 100;
Cleaning up listeners
function onData(data) {
console.log(data);
}
emitter.on('data', onData);
// Later, when you no longer need it
emitter.off('data', onData);
With once, the listener is automatically removed after one invocation:
emitter.once('ready', () => {
console.log('Ready! This listener is now removed.');
});
Async event handling
Listeners are called synchronously by default. If a listener performs async work, errors may not propagate as expected:
// BAD: unhandled promise rejection
emitter.on('data', async (data) => {
await processData(data); // If this throws, it's not caught
});
To handle async listeners properly:
// Option 1: catch inside the listener
emitter.on('data', async (data) => {
try {
await processData(data);
} catch (err) {
emitter.emit('error', err);
}
});
// Option 2: use events.on() for async iteration
const { on } = require('events');
async function processEvents(emitter) {
for await (const [data] of on(emitter, 'data')) {
await processData(data);
}
}
events.once() for promises
Convert a one-time event to a promise:
const { once } = require('events');
async function waitForReady(server) {
await once(server, 'listening');
console.log('Server is ready');
}
// Practical example: wait for server to start
const http = require('http');
const server = http.createServer();
server.listen(3000);
await once(server, 'listening');
console.log('Server running on port 3000');
events.on() for async iteration
const { on } = require('events');
const { createReadStream } = require('fs');
const readline = require('readline');
async function processLines(filePath) {
const rl = readline.createInterface({
input: createReadStream(filePath),
});
for await (const [line] of on(rl, 'line')) {
console.log('Line:', line);
}
}
Typed event emitter pattern
For better developer experience, create a typed wrapper:
class TypedEmitter extends EventEmitter {
/**
* @param {'connect'} event
* @param {(port: number) => void} listener
*/
on(event, listener) {
return super.on(event, listener);
}
}
In TypeScript, this is much more powerful:
interface ServerEvents {
connect: [port: number];
disconnect: [reason: string];
data: [payload: Buffer, metadata: { timestamp: number }];
error: [err: Error];
}
class Server extends EventEmitter<ServerEvents> {
start(port: number) {
// TypeScript knows the shape of each event
this.emit('connect', port); // OK
this.emit('connect', 'wrong'); // Type error
}
}
const server = new Server();
server.on('data', (payload, metadata) => {
// payload is Buffer, metadata is { timestamp: number }
});
Pattern: event bus
A centralized event bus for decoupled communication between modules:
// eventBus.js
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.setMaxListeners(50);
module.exports = bus;
// userService.js
const bus = require('./eventBus');
function createUser(data) {
const user = saveToDb(data);
bus.emit('user:created', user);
return user;
}
// emailService.js
const bus = require('./eventBus');
bus.on('user:created', (user) => {
sendWelcomeEmail(user.email);
});
// analyticsService.js
const bus = require('./eventBus');
bus.on('user:created', (user) => {
trackSignup(user.id);
});
The user service does not know about emails or analytics. New subscribers can be added without modifying existing code.
Pattern: event forwarding
Forward events from one emitter to another:
class DatabasePool extends EventEmitter {
constructor() {
super();
this.connections = [];
}
addConnection(conn) {
this.connections.push(conn);
// Forward connection events to the pool
conn.on('error', (err) => this.emit('connectionError', conn, err));
conn.on('close', () => this.emit('connectionClosed', conn));
conn.on('query', (sql) => this.emit('query', conn, sql));
}
}
AbortSignal integration
Use AbortController to cancel event listeners:
const { once } = require('events');
const ac = new AbortController();
// Cancel after 5 seconds
setTimeout(() => ac.abort(), 5000);
try {
await once(emitter, 'data', { signal: ac.signal });
console.log('Got data');
} catch (err) {
if (err.code === 'ABORT_ERR') {
console.log('Timed out waiting for data');
}
}
Summary
EventEmitter is the observer pattern in Node.js. Extend it to create domain-specific event sources. Always handle the error event to prevent crashes. Watch for memory leaks by cleaning up listeners and monitoring listenerCount. Use events.once() and events.on() to bridge events with promises and async iteration. The event bus pattern decouples modules without adding dependencies between them.
Related articles
- Node.js Node.js Native Test Runner
Learn to write tests with Node.js built-in test runner. Covers test organization, assertions, mocking, subtests, code coverage, and watch mode.
- Node.js Node.js Streams: A Practical Guide
Master Node.js streams for efficient data processing. Covers readable, writable, transform, and duplex streams with real-world examples.
- Node.js Worker Threads in Node.js
Learn how to use Worker Threads for CPU-intensive tasks in Node.js. Covers thread creation, message passing, SharedArrayBuffer, and thread pools.
- Node.js Build Your First REST API with Express
A hands-on tutorial — install Express, define GET/POST/PUT/DELETE routes, parse JSON bodies, use route params, and return proper status codes for an in-memory todos API.