Back/Module F-9 Event Emitters & Node's Event-Driven Core
Module F-9·18 min read

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:

  1. Lets you register named listeners — functions to call when a specific event occurs
  2. Lets you emit events by name — which synchronously calls all registered listeners

It is the observer pattern, built into the Node.js runtime.

javascript
import { 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

javascript
import { 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.

javascript
import { 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.

javascript
const 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:

  1. Register a default 'error' listener in the constructor, or
  2. 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:

javascript
emitter.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:

javascript
emitter.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:

javascript
import 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):

javascript
server.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:

javascript
import 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:

javascript
process.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:

PatternBest For
CallbacksSimple one-time async operations. Legacy APIs.
Promises / async-awaitOne-time operations with a clear success/failure. Most application code.
EventEmitterMultiple 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() or once() appropriately.
  • EventEmitter is synchronous. Listeners run inline when emit() is called. Async listeners need try/catch inside them.
  • Node.js internals use EventEmitter everywhere — HTTP server, IncomingMessage, streams, process itself.
  • 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.

Discussion