Event Emitters

Node.js Intermediate — Event Emitters

Node.js Intermediate

Chapter 2 of 8  ·  Event Emitters

Event Emitters

You've been using EventEmitter since Chapter 1 without knowing it — readable.on('data', ...), writable.on('error', ...), server.on('request', ...) are all EventEmitter in action. Every stream, every HTTP server, every child process in Node is built on the same EventEmitter foundation. This chapter shows you how to use it directly and build your own event-driven classes.

The Observer Pattern

EventEmitter implements the observer pattern: an object (the emitter) maintains a list of listeners and notifies them by name when something happens. The emitter doesn't know or care who is listening — it just fires the event and moves on.

Emitter

The object that fires events. Calls emit('eventName', ...args) when something noteworthy happens. Doesn't know who is listening.

Listener

A function registered with on('eventName', fn). Called synchronously each time the named event is emitted. Multiple listeners can be registered for the same event.

One-time listener

once('eventName', fn) registers a listener that auto-removes itself after firing once. Ideal for setup events: 'connect', 'open', 'ready'.

Removal

off('eventName', fn) (alias: removeListener) removes a specific listener. removeAllListeners('eventName') clears all listeners for that event.

Basic Usage

const { EventEmitter } = require('events'); const emitter = new EventEmitter(); // Register a listener emitter.on('greet', (name) => { console.log(`Hello, ${name}!`); }); // Register another listener for the same event emitter.on('greet', (name) => { console.log(`Hi there, ${name}!`); }); // Emit fires ALL registered listeners in registration order emitter.emit('greet', 'Philip'); // → Hello, Philip! // → Hi there, Philip!
// once() — fires exactly once, then auto-removes emitter.once('connect', () => { console.log('Connected — this fires once only'); }); emitter.emit('connect'); // → Connected — this fires once only emitter.emit('connect'); // → (nothing — listener is gone) // off() / removeListener() — manual removal const handler = (data) => console.log('data:', data); emitter.on('data', handler); emitter.emit('data', 42); // → data: 42 emitter.off('data', handler); // remove it emitter.emit('data', 99); // → (nothing)

💡 emit() is Synchronous

Unlike browser DOM events, Node's emit() calls all listeners synchronously and in order before returning. If a listener throws, subsequent listeners in that emit call don't run. Keep listeners fast — defer heavy work with setImmediate() or process.nextTick() if needed.

Inspecting Listeners

const ee = new EventEmitter(); ee.on('data', () => {}); ee.on('data', () => {}); ee.on('end', () => {}); console.log(ee.listenerCount('data')); // 2 console.log(ee.eventNames()); // ['data', 'end'] console.log(ee.listeners('data')); // [Function, Function] ee.removeAllListeners('data'); console.log(ee.listenerCount('data')); // 0

Custom EventEmitter Classes

The real power comes from extending EventEmitter. This gives your class a fully event-driven API — callers subscribe to events rather than passing callbacks, which scales to multiple subscribers naturally.

const { EventEmitter } = require('events'); const fs = require('fs'); class FileWatcher extends EventEmitter { constructor(filePath, interval = 1000) { super(); this.filePath = filePath; this.interval = interval; this._timer = null; this._lastSize = null; } start() { this._timer = setInterval(() => { try { const { size } = fs.statSync(this.filePath); if (this._lastSize !== null && size !== this._lastSize) { this.emit('change', { path: this.filePath, size }); } this._lastSize = size; } catch (err) { this.emit('error', err); // always emit errors on the 'error' event } }, this.interval); this.emit('start', this.filePath); return this; // allow chaining: watcher.start().on('change', ...) } stop() { clearInterval(this._timer); this.emit('stop'); return this; } } // Usage — multiple independent subscribers, zero callback nesting const watcher = new FileWatcher('app.log', 500); watcher .on('start', (path) => console.log(`Watching: ${path}`)) .on('change', ({ path, size }) => console.log(`Changed — new size: ${size}`)) .on('stop', () => console.log('Stopped watching')) .on('error', (err) => console.error('Error:', err.message)) .start(); setTimeout(() => watcher.stop(), 10000);

The Special 'error' Event

The 'error' event is special in Node — if it fires and there is no listener registered for it, Node throws the error and crashes the process. Always attach an 'error' listener to any EventEmitter that could fail.

const ee = new EventEmitter(); // DANGEROUS — no 'error' listener: // ee.emit('error', new Error('boom')); // → Uncaught Error: boom — crashes the process // SAFE — always add an error handler: ee.on('error', (err) => { console.error('Handled error:', err.message); }); ee.emit('error', new Error('boom')); // → Handled error: boom

Memory Leaks and maxListeners

Each call to on() adds a listener. If you add listeners inside a loop or repeatedly without removing them, they accumulate. Node warns you when a single event has more than 10 listeners — this is usually a sign of a leak.

const ee = new EventEmitter(); // Adding listeners in a loop — classic leak pattern function setup() { ee.on('data', (d) => console.log(d)); // adds a NEW listener every call } for (let i = 0; i < 15; i++) setup(); // MaxListenersExceededWarning: Possible EventEmitter memory leak detected // Fix 1: raise the limit if you genuinely need many listeners ee.setMaxListeners(20); // Fix 2: remove before re-adding function setupSafe(handler) { ee.off('data', handler); // remove old one first ee.on('data', handler); // then add fresh } // Fix 3: use once() for one-shot subscriptions ee.once('data', (d) => console.log('first data:', d));

💡 prependListener() and prependOnceListener()

Listeners fire in registration order by default. If you need a listener to run before all others (e.g., an audit log that must capture the event first), use prependListener('event', fn) to insert it at the front of the queue rather than the back.

EventEmitter vs Callbacks vs Promises

Each pattern has a natural home:

PatternBest forExample
Callback A single async result that happens once fs.readFile(path, cb)
Promise / async-await A single async result, with cleaner chaining await fs.promises.readFile(path)
EventEmitter Multiple events over time, multiple subscribers, ongoing state stream.on('data', ...), server.on('request', ...)

Practical Pattern — EventEmitter + async/await

Sometimes you need to wait for a one-time event in an async function. events.once() wraps a one-time listener in a Promise.

const { once } = require('events'); async function waitForReady(emitter) { // Waits for the 'ready' event as if it were a Promise const [data] = await once(emitter, 'ready'); console.log('Ready with data:', data); } const ee = new EventEmitter(); waitForReady(ee); setTimeout(() => ee.emit('ready', { status: 'ok' }), 500); // → Ready with data: { status: 'ok' }

Quick Reference

MethodWhat it does
on(event, fn)Add a persistent listener (fires every time)
once(event, fn)Add a listener that auto-removes after one fire
emit(event, ...args)Fire all listeners for this event synchronously
off(event, fn)Remove a specific listener (pass same function reference)
removeAllListeners(event)Remove all listeners for an event (or all events if omitted)
listenerCount(event)How many listeners are registered for this event
eventNames()Array of all event names that have listeners
setMaxListeners(n)Raise the warning threshold (default 10)
prependListener(event, fn)Add a listener at the front of the queue
events.once(emitter, event)Promise that resolves on the next emit of this event

⚠️ Common Mistakes

Forgetting to add an 'error' listener — an unhandled 'error' event crashes the process immediately. Every custom EventEmitter that can fail needs one.

Removing the wrong function referenceoff() requires the exact same function reference passed to on(). Anonymous functions and arrow functions defined inline can't be removed: ee.off('data', () => {}) does nothing because the arrow function is a new object each time.

Expecting async behaviouremit() is synchronous. If listener A emits another event that triggers listener B, B runs before emit() returns to the caller. This can cause re-entrancy bugs in complex event graphs.

Coding Challenges

Challenge 1 — Job Queue

Build a JobQueue class that extends EventEmitter. It should accept jobs (functions) via an add(fn) method and process them one at a time with a configurable concurrency limit. Emit 'start' when a job begins (with the job index), 'done' when it completes (with the result), 'error' if it throws, and 'drain' when the queue is empty.

View sample solution ↗

Challenge 2 — Typed Event Emitter

Create a TypedEmitter class that wraps EventEmitter and validates event payloads against a schema passed in the constructor. If an emitted payload fails validation, emit an 'error' instead of the original event. Define a schema for a 'userLogin' event that requires { userId: string, timestamp: number } and test it with both valid and invalid payloads.

View sample solution ↗

Challenge 3 — Event Pipeline

Build three EventEmitter-based classes — DataProducer, DataTransformer, and DataConsumer — that form a pipeline: Producer emits 'data' on an interval, Transformer listens and emits transformed data, Consumer listens and logs the result. Wire them together without Producer knowing about Transformer or Consumer. Include a clean shutdown that stops the producer and emits a 'done' event that propagates through the chain.

View sample solution ↗