EventEmitter
Node's Native Pub/Sub
LinkedIn Hook
"Why does your Node.js server crash with a single unhandled 'error' event?"
Most developers use Express, Socket.IO, or the
httpmodule every day without realizing they are all built on top of one tiny class — EventEmitter. It is the heartbeat of Node.js. Every stream, every server, every child process, every signal handler is just an EventEmitter under the hood.But here is the catch: EventEmitter has rules that bite hard. Emit an
'error'event with no listener? Your process crashes immediately. Forget to remove a listener inside a long-running loop? Memory leak warning at listener number 11. Assumeemit()is asynchronous? You are wrong — listeners run synchronously, in the order they were registered, on the same call stack.In Lesson 3.3, I break down the EventEmitter API the way it actually works in production — including the modern
events.onasync iterator that turns event streams intofor awaitloops, and the exact reason Node printsMaxListenersExceededWarning.Read the full lesson -> [link]
#NodeJS #EventEmitter #BackendDevelopment #JavaScript #InterviewPrep
What You'll Learn
- What an
EventEmitteris and why it powers most of Node's core APIs - The full listener API:
on,once,off,emit,removeListener,removeAllListeners - The special
'error'event and why an unhandled one crashes your process - How to extend
EventEmitterto build your own event-driven classes - The
MaxListenersExceededWarningand how to raise or silence it correctly - Why
emit()runs listeners synchronously and when to defer withsetImmediate - Real-world examples from
http.Server, streams, and theprocessobject - Modern async helpers
events.onandevents.onceforfor awaitloops - How forgotten listeners cause silent memory leaks in long-running services
The Radio Station Analogy — Broadcasters and Subscribers
Imagine a small FM radio station. The station has one transmitter that broadcasts on a specific frequency. Anyone with a radio can tune in to that frequency and listen. The station does not know who is listening, does not care how many radios are tuned in, and does not wait for any particular listener to respond. It just broadcasts.
When the DJ says "and now, the news", every radio currently tuned to that frequency hears the news at the same time. If nobody is listening, the news is broadcast anyway — it just disappears into the air. If a hundred radios are tuned in, all hundred hear the same broadcast simultaneously.
That is exactly how an EventEmitter works. The emitter is the transmitter. Calling emitter.emit('news', payload) is the DJ shouting into the microphone. Every function registered with emitter.on('news', handler) is a radio tuned to that frequency. Listeners run in registration order, on the same thread, right now — not later, not in parallel.
There is one twist the radio analogy misses: the 'error' frequency. If the station broadcasts an emergency alert on the error frequency and nobody has a receiver tuned in, the entire station explodes. That is the special rule of the 'error' event in Node — an unhandled emit terminates the process.
+---------------------------------------------------------------+
| THE RADIO STATION MODEL |
+---------------------------------------------------------------+
| |
| [ EventEmitter ] |
| | |
| | emit('news', payload) |
| v |
| +---------------+ |
| | broadcast | |
| +---------------+ |
| | | | |
| v v v |
| listener listener listener |
| #1 #2 #3 <-- run sync, in order |
| |
| Special channel: 'error' |
| - if at least one listener: handled normally |
| - if zero listeners: process CRASHES |
| |
+---------------------------------------------------------------+
Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). Center: a green (#68a063) radio tower with three wave rings. Three white monospace receiver boxes labeled 'listener 1', 'listener 2', 'listener 3' connected by dotted lines. To the right, a separate amber (#ffb020) channel labeled 'error' with a red explosion icon and the text 'no listener = crash'. Bottom caption in white monospace: 'emit() is synchronous'."
Example 1 — Basic EventEmitter Usage
The events module ships with Node. You import the EventEmitter class, instantiate it, register listeners with on, and broadcast with emit.
// basic-emitter.js
// Import the EventEmitter class from the built-in events module
const { EventEmitter } = require('events');
// Create a new emitter instance
const bus = new EventEmitter();
// Register a listener for the 'message' event
// This function will run every time someone emits 'message'
bus.on('message', (text, user) => {
console.log(`[on] ${user} said: ${text}`);
});
// Register a one-time listener with once()
// It runs on the first emit, then automatically removes itself
bus.once('message', (text, user) => {
console.log(`[once] first message ever from ${user}`);
});
// Emit the event with two arguments
// All listeners receive the same arguments in registration order
bus.emit('message', 'hello world', 'alice');
// [on] alice said: hello world
// [once] first message ever from alice
bus.emit('message', 'second message', 'bob');
// [on] bob said: second message
// (the once listener is gone now)
// Define a named function so we can remove it later
function greet(name) {
console.log(`hi ${name}`);
}
// Register and then remove it
// off() is an alias for removeListener()
bus.on('greet', greet);
bus.emit('greet', 'carol'); // hi carol
bus.off('greet', greet);
bus.emit('greet', 'dave'); // (nothing — listener removed)
// Inspect listener counts
console.log(bus.listenerCount('message')); // 1 (the once is gone)
console.log(bus.eventNames()); // [ 'message' ]
Key points:
on(event, fn)adds a listener; you can register the same event many times.once(event, fn)auto-removes after the first emit.off(event, fn)(alias ofremoveListener) requires the same function reference you passed toon. Anonymous arrow functions cannot be removed because you do not have a handle to them.emit(event, ...args)returnstrueif the event had listeners,falseif not.- Listeners run synchronously in the order they were added, on the calling thread, before
emit()returns.
Example 2 — The Special 'error' Event
Node treats 'error' as a magic event name. If you emit('error', ...) and there is no listener registered, Node prints the error to stderr and terminates the process with a non-zero exit code. This rule exists because errors that go unhandled in async code have historically been the largest source of silent bugs in Node — Node forces you to be explicit.
// error-event.js
const { EventEmitter } = require('events');
const db = new EventEmitter();
// CASE 1: No listener attached -> process crashes
// Uncomment the next line and run this file by itself:
// db.emit('error', new Error('connection lost'));
//
// Output:
// node:events:497
// throw er; // Unhandled 'error' event
// ^
// Error: connection lost
// Process exits with code 1
// CASE 2: Attach a listener BEFORE emitting -> handled normally
db.on('error', (err) => {
// Log, retry, alert — anything except crash
console.error('[db error]', err.message);
});
// Now emitting is safe; the listener catches it
db.emit('error', new Error('connection lost'));
// [db error] connection lost
// CASE 3: Use a single global safety net for development only
// Production code should attach a real listener to every emitter
const safetyNet = new EventEmitter();
safetyNet.on('error', (err) => {
console.error('[safety net]', err.message);
});
// You can also catch errors from any emitter via the symbol
// events.errorMonitor lets you observe 'error' WITHOUT counting
// as a real listener — the crash rule still applies
const { errorMonitor } = require('events');
const radio = new EventEmitter();
radio.on(errorMonitor, (err) => {
// Observability hook — does NOT prevent the crash
console.log('[monitor] saw error:', err.message);
});
radio.on('error', (err) => {
// This is the real listener that prevents the crash
console.log('[handler] handled:', err.message);
});
radio.emit('error', new Error('static interference'));
// [monitor] saw error: static interference
// [handler] handled: static interference
Why this matters: every stream, every http.Server, every child_process, every net.Socket is an EventEmitter that emits 'error'. If you forget to attach a listener even once in a hot path, a single transient failure can take down your entire Node process.
Example 3 — Extending EventEmitter to Build Your Own Class
The most common production pattern is to subclass EventEmitter so your domain object becomes event-driven. This is exactly how http.Server, net.Socket, and stream.Readable are built.
// chat-room.js
const { EventEmitter } = require('events');
// A ChatRoom IS an EventEmitter — it broadcasts events
// to anyone who subscribes
class ChatRoom extends EventEmitter {
constructor(name) {
// Always call super() so the EventEmitter machinery initializes
super();
this.name = name;
this.users = new Set();
}
join(user) {
this.users.add(user);
// Emit a 'join' event so subscribers can react
this.emit('join', user, this.users.size);
}
leave(user) {
this.users.delete(user);
this.emit('leave', user, this.users.size);
}
send(user, text) {
if (!this.users.has(user)) {
// Emit 'error' for invalid operations
// Subscribers MUST attach a listener or the process crashes
this.emit('error', new Error(`${user} is not in the room`));
return;
}
this.emit('message', { user, text, at: Date.now() });
}
close() {
this.emit('close');
// Drop all listeners so the object can be garbage collected
this.removeAllListeners();
}
}
// --- usage ---
const room = new ChatRoom('general');
// Subscribe to lifecycle events
room.on('join', (user, count) => console.log(`+ ${user} (${count})`));
room.on('leave', (user, count) => console.log(`- ${user} (${count})`));
room.on('message', (msg) => console.log(`${msg.user}: ${msg.text}`));
room.on('error', (err) => console.error('[room error]', err.message));
room.on('close', () => console.log(`room ${room.name} closed`));
room.join('alice'); // + alice (1)
room.join('bob'); // + bob (2)
room.send('alice', 'hello'); // alice: hello
room.send('eve', 'hi'); // [room error] eve is not in the room
room.leave('alice'); // - alice (1)
room.close(); // room general closed
This pattern — subclass, emit lifecycle events, document them — is the idiomatic Node way to expose async behavior. Consumers compose your class with whatever logging, metrics, or business logic they need by attaching listeners.
Example 4 — The MaxListeners Warning and Memory Leaks
By default, every EventEmitter has a soft cap of 10 listeners per event. When you exceed it, Node prints a warning to stderr:
(node:1234) MaxListenersExceededWarning: Possible EventEmitter memory leak
detected. 11 message listeners added to [EventEmitter]. Use emitter.setMaxListeners()
to increase limit
The warning exists because the most common cause of growing listener counts is a bug: code that adds a listener inside a loop, request handler, or interval and forgets to remove it. Each forgotten listener keeps a closure alive, which keeps every variable in that closure alive — a textbook memory leak.
// leak-demo.js
const { EventEmitter } = require('events');
const bus = new EventEmitter();
// --- THE BUG ---
// Pretend this runs once per HTTP request in a long-lived server
function handleRequest(reqId) {
// BAD: a new listener is added every request and NEVER removed
bus.on('shutdown', () => {
console.log(`request ${reqId} cleaning up`);
});
}
// Simulate 12 requests
for (let i = 0; i < 12; i++) {
handleRequest(i);
}
// Output around iteration 11:
// (node:xxxx) MaxListenersExceededWarning: Possible EventEmitter memory leak
// detected. 11 shutdown listeners added.
console.log('listeners:', bus.listenerCount('shutdown')); // 12 (and growing)
// --- THE FIX 1: use once() so the listener removes itself ---
const bus2 = new EventEmitter();
function handleRequestFixed(reqId) {
bus2.once('shutdown', () => {
console.log(`request ${reqId} cleaning up`);
});
}
// Still problematic if shutdown never fires, but at least listeners
// die after the first emit.
// --- THE FIX 2: keep a reference and remove it explicitly ---
const bus3 = new EventEmitter();
function handleRequestProper(reqId) {
const onShutdown = () => console.log(`request ${reqId} cleaning up`);
bus3.on('shutdown', onShutdown);
// When the request finishes, remove the listener
return () => bus3.off('shutdown', onShutdown);
}
const cleanup = handleRequestProper(42);
// ... later, when the request is done:
cleanup();
// --- WHEN A HIGH LIMIT IS LEGITIMATE ---
// Sometimes you genuinely need many listeners (e.g. a pub/sub bus
// with thousands of subscribers). Raise the cap explicitly:
const hub = new EventEmitter();
hub.setMaxListeners(1000); // per-instance limit
// EventEmitter.defaultMaxListeners = 50; // global default (avoid)
// Set to 0 to disable the warning entirely (use with caution)
// hub.setMaxListeners(0);
Rule of thumb: if you find yourself reaching for setMaxListeners, first ask whether you actually meant to call once() or whether your on() calls are missing matching off() calls. The warning is almost always a real bug.
Sync Emission, AsyncIterator, and Real-World Usage
emit() is synchronous
This trips up almost every Node developer at least once. When you call emit('x'), every listener runs right now, on the same call stack, before emit() returns. There is no queue, no microtask, no setImmediate. If a listener throws and you do not catch it, it propagates straight back into your emit caller.
const { EventEmitter } = require('events');
const e = new EventEmitter();
e.on('go', () => console.log('1: listener'));
console.log('A: before emit');
e.emit('go'); // runs the listener synchronously
console.log('B: after emit');
// A: before emit
// 1: listener
// B: after emit
If you need a listener to run asynchronously — for example to avoid surprising callers with re-entrancy — wrap the body in setImmediate or queueMicrotask yourself:
e.on('go', () => {
setImmediate(() => {
// Now this runs on the next tick, not inside emit()
doExpensiveWork();
});
});
Real-world emitters in Node core
Almost every async API in Node is an EventEmitter. Memorize these — they show up in interviews constantly:
http.Serveremits'request','connection','close','clientError'.stream.Readableemits'data','end','error','close'.stream.Writableemits'drain','finish','error'.child_process.ChildProcessemits'exit','close','error','message'.processemits'exit','uncaughtException','unhandledRejection','SIGINT','SIGTERM'.
const http = require('http');
const server = http.createServer();
// http.Server IS an EventEmitter
server.on('request', (req, res) => {
res.end('hello');
});
server.on('clientError', (err, socket) => {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
server.listen(3000);
events.on and events.once — async helpers
Modern Node ships two async helpers in the events module that turn event streams into Promise-friendly code.
const { EventEmitter, on, once } = require('events');
const bus = new EventEmitter();
// once() returns a Promise that resolves with the next emit's args
async function waitForReady() {
const [config] = await once(bus, 'ready');
console.log('ready with config:', config);
}
waitForReady();
setTimeout(() => bus.emit('ready', { port: 3000 }), 100);
// on() returns an async iterator — perfect for `for await`
async function consumeMessages() {
// Each iteration yields the args array of one emit
for await (const [msg] of on(bus, 'message')) {
console.log('got:', msg);
if (msg === 'stop') break; // breaking the loop unsubscribes
}
}
consumeMessages();
bus.emit('message', 'hi');
bus.emit('message', 'world');
bus.emit('message', 'stop');
events.on is the cleanest way to consume an event stream in modern async code. It handles backpressure, cleans up the listener when you break, and rejects the iterator if the emitter emits 'error'.
Common Mistakes
1. Forgetting to attach an 'error' listener.
Streams, sockets, child processes, and HTTP servers all emit 'error'. Miss one and a transient network blip crashes your entire Node process. Always attach an error listener to every emitter you create or receive — even if the handler just logs and moves on.
2. Using anonymous arrow functions with on() and then trying to remove them.
emitter.off(event, fn) requires the exact same function reference you passed to on. If you wrote bus.on('x', () => {...}), you no longer have a handle to that function and cannot remove it. Always store named function references when you intend to remove a listener later.
3. Assuming emit() is asynchronous.
Listeners run synchronously, in registration order, on the calling stack — before emit() returns. If you emit('x') and then read state on the next line, that state already reflects whatever the listeners did. If you need async behavior, wrap the listener body in setImmediate or queueMicrotask.
4. Adding listeners inside request handlers without removing them.
Every HTTP request that registers a new listener on a long-lived emitter leaks one closure per request. After 11 listeners on the same event, you get the MaxListenersExceededWarning — that warning is almost always a real bug, not a false alarm. Use once() or pair every on() with an off() in your cleanup path.
5. Calling setMaxListeners(0) to silence the warning instead of fixing the leak.
Disabling the warning hides the symptom while the leak grows. Only raise the cap when you have audited the code and confirmed the high listener count is intentional (e.g. a pub/sub hub serving thousands of subscribers).
Interview Questions
1. "What is an EventEmitter and where is it used in Node core?"
EventEmitter is the class exported from the built-in events module that implements the observer (publisher/subscriber) pattern. An emitter holds a map of event name to array of listener functions. Calling on(event, fn) adds a listener; calling emit(event, ...args) invokes every listener for that event synchronously, in registration order, with the args you passed. Almost every async API in Node core inherits from it: http.Server, every readable and writable stream, child_process.ChildProcess, net.Socket, process, fs.ReadStream, and so on. Understanding EventEmitter is understanding how Node's async APIs talk to your code.
2. "What happens if you emit an 'error' event with no listener attached, and why?"
Node treats 'error' as a special event name. If you call emit('error', err) and the emitter has zero listeners for 'error', Node throws the error out of the emit call, which — because emit runs synchronously on the calling stack — propagates up as an uncaught exception and terminates the process with a non-zero exit code. The reason is historical: errors in event-driven code used to be silently swallowed, causing painful production bugs. Forcing a crash makes the missing handler impossible to ignore. Production code must attach an 'error' listener to every emitter, even if the handler just logs the error.
3. "Are EventEmitter listeners synchronous or asynchronous? Explain the implications."
They are synchronous. When you call emit('x', payload), Node iterates the listener array for 'x' and calls each function one after another on the same call stack, in the order they were registered, before emit() returns. Implications: (1) a listener that throws crashes the emit caller unless wrapped in try/catch; (2) state mutations made by listeners are visible immediately after emit returns; (3) a slow listener blocks every other listener and the calling code; (4) if you need true async behavior, the listener itself must wrap its body in setImmediate, queueMicrotask, or return a promise that the caller awaits separately. emit does not await promises returned from listeners.
4. "What is the MaxListenersExceededWarning and how do you handle it correctly?"
By default, each EventEmitter has a soft cap of 10 listeners per event name. When you add an 11th listener for the same event on the same emitter, Node prints MaxListenersExceededWarning: Possible EventEmitter memory leak detected. The warning exists because the most common cause of growing listener counts is a bug — typically, code that registers a new listener on every request, timer tick, or loop iteration without removing it. Each forgotten listener keeps a closure alive, leaking memory. The correct fix is almost always to find the missing off() call or switch to once(). Only when you have audited the code and confirmed the high count is intentional (e.g. a pub/sub bus with hundreds of legitimate subscribers) should you call emitter.setMaxListeners(n) to raise the cap. Calling setMaxListeners(0) disables the check entirely and should be a last resort.
5. "What does events.on(emitter, eventName) do, and how is it different from emitter.on()?"
events.on is an async helper exported from the events module that turns an event stream into an async iterator. Instead of attaching a callback, you write for await (const args of events.on(emitter, 'message')) { ... }. Each iteration of the loop yields the args array of one emit call. Under the hood it buffers events that arrive between iterations, cleans up the listener automatically when you break or return out of the loop, and rejects the iterator if the emitter emits 'error'. It is the modern, Promise-friendly way to consume an event stream — much cleaner than nested callbacks. The companion events.once(emitter, eventName) returns a Promise that resolves with the args of the next single emit, useful for awaiting one-shot lifecycle events like 'ready' or 'open'.
Quick Reference — EventEmitter Cheat Sheet
+---------------------------------------------------------------+
| EVENTEMITTER API CHEAT SHEET |
+---------------------------------------------------------------+
| |
| CREATE: |
| const { EventEmitter } = require('events') |
| const bus = new EventEmitter() |
| |
| REGISTER: |
| bus.on(event, fn) // every emit |
| bus.once(event, fn) // first emit only |
| bus.prependListener(e, fn) // insert at front |
| |
| EMIT (synchronous!): |
| bus.emit(event, ...args) // returns true/false |
| |
| REMOVE: |
| bus.off(event, fn) // alias removeListener |
| bus.removeAllListeners(e) // nuke one event |
| bus.removeAllListeners() // nuke everything |
| |
| INSPECT: |
| bus.listenerCount(event) |
| bus.eventNames() |
| bus.listeners(event) |
| |
| LIMITS: |
| bus.setMaxListeners(n) // 0 = unlimited |
| EventEmitter.defaultMaxListeners = 10 |
| |
| ASYNC HELPERS: |
| const { on, once } = require('events') |
| await once(bus, 'ready') // promise |
| for await (const [m] of on(bus, 'x')) // iterator |
| |
+---------------------------------------------------------------+
+---------------------------------------------------------------+
| KEY RULES |
+---------------------------------------------------------------+
| |
| 1. emit() runs listeners SYNCHRONOUSLY, in order |
| 2. 'error' with no listener -> process CRASH |
| 3. off() needs the SAME function reference as on() |
| 4. 11+ listeners on one event -> memory leak warning |
| 5. once() auto-removes after first emit |
| 6. Subclass EventEmitter for domain objects |
| 7. Use events.on / events.once for async/await |
| 8. Wrap listener body in setImmediate to defer work |
| |
+---------------------------------------------------------------+
| Method | Purpose | Returns |
|---|---|---|
on(e, fn) | Add listener (every emit) | this |
once(e, fn) | Add one-shot listener | this |
off(e, fn) | Remove specific listener | this |
emit(e, ...args) | Synchronously invoke listeners | boolean |
removeAllListeners(e?) | Nuke listeners for event (or all) | this |
listenerCount(e) | How many listeners on this event | number |
setMaxListeners(n) | Raise/lower per-event cap | this |
events.once(em, e) | Promise of next emit's args | Promise |
events.on(em, e) | Async iterator over emits | AsyncIterator |
Prev: Lesson 3.2 -- path and os Next: Lesson 3.4 -- Building an HTTP Server From Scratch
This is Lesson 3.3 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.