The EventEmitter class, on/emit/once/removeListener, custom events, why streams and HTTP are built on EventEmitter, and memory leak prevention.
Module F-9 — Event Emitters & Node's Event-Driven Core
What this module covers: The EventEmitter class is the foundation of Node.js's architecture. HTTP servers, file streams, database connections, WebSockets — they all inherit from EventEmitter. You have already used it without knowing it: every time you called server.listen() or req.on('data', ...), you were using an EventEmitter. This module explains how EventEmitter works, how to build your own, how to avoid the memory leak that trips up every developer, and how the pattern underpins Node.js internals. Understanding this bridges Foundation to everything in the Practitioner and Architect phases.
What Is an Event Emitter?
An EventEmitter is an object that:
- Lets you register named listeners — functions to call when a specific event occurs
- Lets you emit events by name — which synchronously calls all registered listeners
It is the observer pattern, built into the Node.js runtime.
javascriptimport { EventEmitter } from 'node:events'; const emitter = new EventEmitter(); // Register a listener for the 'greet' event emitter.on('greet', (name) => { console.log(`Hello, ${name}!`); }); // Emit the event — calls all registered 'greet' listeners emitter.emit('greet', 'Jatin'); // → Hello, Jatin! emitter.emit('greet', 'World'); // → Hello, World!
Simple enough. The power comes from the fact that the emitter and the listener are decoupled — the code that fires the event does not know who is listening, and the listener does not know when or why the event was fired.
Core EventEmitter API
javascriptimport { EventEmitter } from 'node:events'; const emitter = new EventEmitter(); // on() — listen for every occurrence of an event emitter.on('data', (chunk) => { console.log('Received:', chunk); }); // once() — listen only once, then automatically remove the listener emitter.once('connected', () => { console.log('Connected to database'); }); // emit() — fire an event with optional arguments emitter.emit('data', Buffer.from('hello')); emitter.emit('connected'); emitter.emit('connected'); // second emit — the 'once' listener is gone // off() / removeListener() — remove a specific listener function onError(err) { console.error('Error:', err.message); } emitter.on('error', onError); emitter.off('error', onError); // remove it later // removeAllListeners() — remove all listeners for an event (or all events) emitter.removeAllListeners('data'); emitter.removeAllListeners(); // clear everything // listenerCount() — how many listeners are registered for an event console.log(emitter.listenerCount('data')); // 0 // eventNames() — list all events that have listeners console.log(emitter.eventNames());
Building Your Own EventEmitter Subclass
The real pattern: extend EventEmitter in your own classes. This is how Node.js HTTP servers, streams, and virtually every I/O object are built.
javascriptimport { EventEmitter } from 'node:events'; class DataPipeline extends EventEmitter { #isRunning = false; #processed = 0; start() { if (this.#isRunning) return; this.#isRunning = true; this.emit('started'); // Simulate processing records const interval = setInterval(() => { this.#processed++; const record = { id: this.#processed, value: Math.random() }; this.emit('record', record); // emit each record if (this.#processed >= 5) { clearInterval(interval); this.#isRunning = false; this.emit('finished', this.#processed); } }, 100); } stop() { this.#isRunning = false; this.emit('stopped'); } } // Usage const pipeline = new DataPipeline(); pipeline.on('started', () => console.log('Pipeline started')); pipeline.on('record', (record) => console.log('Processing:', record)); pipeline.on('finished', (count) => console.log(`Done. Processed ${count} records.`)); pipeline.start();
Output:
Pipeline started
Processing: { id: 1, value: 0.4521... }
Processing: { id: 2, value: 0.8734... }
...
Done. Processed 5 records.
The caller does not need to know anything about the pipeline's internals. It just listens for named events.
The error Event Is Special
If an EventEmitter emits an 'error' event and there is no listener registered for it, Node.js throws the error and crashes the process. This is not optional — it is a design decision to force you to handle errors explicitly.
javascriptconst emitter = new EventEmitter(); // ❌ This will crash the process emitter.emit('error', new Error('Something went wrong')); // → Uncaught Error: Something went wrong // ✅ Always register an error listener emitter.on('error', (err) => { console.error('Emitter error:', err.message); }); emitter.emit('error', new Error('Something went wrong')); // → Emitter error: Something went wrong
Any class extending EventEmitter should either:
- Register a default
'error'listener in the constructor, or - Document clearly that callers must register one
The Memory Leak Warning
This is the single most common EventEmitter mistake. By default, Node.js warns if you register more than 10 listeners for the same event on the same emitter:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 'data' listeners added to [EventEmitter].
Why? If you accidentally register a new listener on every request without removing the old one, you have a memory leak — the listener count grows unboundedly and the listeners are never garbage collected.
javascript// ❌ BAD — registers a new listener on every call app.get('/stream', (req, res) => { pipeline.on('record', (record) => { res.write(JSON.stringify(record)); }); }); // After 11 requests: MaxListenersExceededWarning // After 1000 requests: 1000 listeners, memory leak
The fix: remove listeners when you are done with them.
javascript// ✅ CORRECT — remove the listener when the request ends app.get('/stream', (req, res) => { function onRecord(record) { res.write(JSON.stringify(record) + '\n'); } pipeline.on('record', onRecord); // Remove when client disconnects req.on('close', () => { pipeline.off('record', onRecord); }); });
If you genuinely need many listeners (e.g. many subscribers to a shared emitter), increase the limit:
javascriptemitter.setMaxListeners(50); // or globally: EventEmitter.defaultMaxListeners = 50;
But increasing the limit should be a deliberate decision with a comment explaining why — not a way to silence the warning.
Synchronous vs Asynchronous Listeners
EventEmitter is synchronous by default. emit() calls all listeners immediately, in the order they were registered, before returning. There is no queuing or async scheduling:
javascriptemitter.on('ping', () => console.log('listener 1')); emitter.on('ping', () => console.log('listener 2')); console.log('before emit'); emitter.emit('ping'); console.log('after emit'); // Output: // before emit // listener 1 // listener 2 // after emit
This means if a listener does heavy synchronous work, it blocks the event loop — same as any other synchronous code. Keep listeners fast, or offload heavy work:
javascript// ❌ Blocks the event loop emitter.on('request', (data) => { const result = heavyCpuWork(data); // blocks everything saveToDb(result); }); // ✅ Don't block — hand off async work emitter.on('request', async (data) => { const result = await heavyCpuWork(data); // if async await saveToDb(result); });
Note: if a listener is async and throws, the rejection is unhandled by default. The emitter does not await its listeners. For async event handling in production, always add try/catch inside the listener or use a wrapper.
How Node.js Uses EventEmitter Internally
You have been using EventEmitters since F-1 without realising it. Every major I/O object in Node.js extends EventEmitter:
HTTP Server:
javascriptimport http from 'node:http'; const server = http.createServer(); // These are EventEmitter.on() calls server.on('request', (req, res) => { res.end('Hello'); }); server.on('error', (err) => { console.error('Server error:', err); }); server.on('close', () => { console.log('Server closed'); }); server.listen(3000);
http.createServer() returns an http.Server which extends EventEmitter. The request callback shorthand (createServer(handler)) is simply .on('request', handler).
HTTP Request (IncomingMessage):
javascriptserver.on('request', (req, res) => { let body = ''; req.on('data', (chunk) => { // stream of data chunks body += chunk.toString(); }); req.on('end', () => { // all data received console.log('Body:', body); res.end('Received'); }); req.on('error', (err) => { console.error('Request error:', err); }); });
File Streams:
javascriptimport fs from 'node:fs'; const readable = fs.createReadStream('./large-file.csv'); readable.on('data', (chunk) => { // process chunk }); readable.on('end', () => { console.log('File fully read'); }); readable.on('error', (err) => { console.error('Stream error:', err); });
Process itself:
javascriptprocess.on('exit', (code) => { console.log('Exiting with code:', code); }); process.on('SIGTERM', () => { console.log('Received SIGTERM — shutting down'); process.exit(0); });
process is itself an EventEmitter.
EventEmitter vs Callbacks vs Promises
You now have three async patterns. When to use each:
| Pattern | Best For |
|---|---|
| Callbacks | Simple one-time async operations. Legacy APIs. |
| Promises / async-await | One-time operations with a clear success/failure. Most application code. |
| EventEmitter | Multiple events over time. Streams of data. Pub/sub within a process. Multiple listeners for the same event. |
The key distinction: Promises resolve once. EventEmitters fire many times. A file read finishes once → Promise. A server receives many requests → EventEmitter. A data pipeline processes thousands of records → EventEmitter.
Practical Pattern: Internal Event Bus
A lightweight event bus for decoupling modules inside a single Node.js process:
javascript// src/events/bus.js import { EventEmitter } from 'node:events'; // Singleton event bus — shared across the application export const bus = new EventEmitter(); bus.setMaxListeners(50); // Event name constants — prevents typos export const Events = { USER_CREATED: 'user.created', POST_PUBLISHED: 'post.published', ORDER_PLACED: 'order.placed', EMAIL_REQUESTED: 'email.requested', };
javascript// src/routes/users.js import { bus, Events } from '../events/bus.js'; router.post('/', async (req, res, next) => { try { const user = await prisma.user.create({ data: req.body }); res.status(201).json(user); // Emit after responding — don't block the response bus.emit(Events.USER_CREATED, user); } catch (err) { next(err); } });
javascript// src/listeners/email.js import { bus, Events } from '../events/bus.js'; bus.on(Events.USER_CREATED, async (user) => { try { await sendWelcomeEmail(user.email, user.name); } catch (err) { console.error('Failed to send welcome email:', err.message); // Don't crash the process — log and move on } });
javascript// src/index.js — register listeners at startup import './listeners/email.js'; import './listeners/analytics.js';
This pattern — emit from routes, listen in separate modules — keeps business logic out of HTTP handlers and makes the system easy to extend. Adding a new action when a user is created means adding a new listener file, not modifying the route.
Summary
- EventEmitter is Node.js's observer pattern.
on()registers listeners,emit()fires them. once()for one-time listeners.off()to remove them.listenerCount()to check how many are attached.- Extend EventEmitter for your own classes that emit events over time (pipelines, connection managers, worker pools).
- Always register an
'error'listener. An unhandled'error'event crashes the process. - Memory leaks happen when you register listeners without removing them. Use
off()oronce()appropriately. - EventEmitter is synchronous. Listeners run inline when
emit()is called. Async listeners needtry/catchinside them. - Node.js internals use EventEmitter everywhere — HTTP server, IncomingMessage, streams,
processitself. - Use EventEmitters for streams of events over time. Use Promises for one-time results. Use callbacks only when working with legacy APIs.
This completes Phase 1 — Foundation. You now have the mental models, tools, and patterns to understand and build basic Node.js applications. Phase 2 takes all of this and builds it into production-quality application architecture: authentication, TypeScript, testing, security, and deployment.
Phase 1 recap: Runtime model → Module system → File I/O → Async programming → npm → Express APIs → Databases → Project structure → Event-driven core. Every concept in Phase 2 and Phase 3 builds directly on one or more of these.