Events and EventEmitter
EventEmitter is Node's built-in publish/subscribe primitive behind streams, servers and sockets. Watch emit() call listeners synchronously in order, see once() remove itself, and learn why an unhandled 'error' event crashes the process.
🎯 What you'll learn
- The core pattern and extending EventEmitter
- on vs once vs off
- Multiple listeners, removal and leak warnings
- Error events, async listeners and the internals
📚 Before you start
1The Core Pattern
EventEmitter (from the built-in events module) is the foundation Node's own APIs are built on — streams, http.Server, child_process, process itself all extend or use it. It implements the classic observer pattern: objects emit named events, and any number of listener functions can subscribe to react when that event fires.
const EventEmitter = require("events");
const emitter = new EventEmitter();
emitter.on("greet", (name) => {
console.log(`Hello, ${name}!`);
});
emitter.emit("greet", "Ana"); // "Hello, Ana!" — runs listener(s) SYNCHRONOUSLY, right here.emit() calls every registered listener for that event synchronously, in registration order, before .emit() itself returns — this is not a queued or async dispatch (contrast with a browser's DOM events, which can involve capture/bubble phases and different scheduling).
2Extending EventEmitter — the Standard Node Pattern
Almost nobody instantiates EventEmitter directly in real code; the standard pattern is to extend it so your own class becomes an emitter:
const EventEmitter = require("events");
class Downloader extends EventEmitter {
start(url) {
this.emit("start", url);
// ... simulate work ...
setTimeout(() => this.emit("progress", 50), 10);
setTimeout(() => this.emit("done", { url, bytes: 1024 }), 20);
}
}
const dl = new Downloader();
dl.on("start", (url) => console.log("starting", url));
dl.on("progress", (pct) => console.log("progress:", pct));
dl.on("done", (result) => console.log("done:", result));
dl.start("http://example.com/file.zip");This is exactly how fs.createReadStream, http.Server, and child_process.spawn()'s return values work internally — they're all EventEmitter subclasses with domain-specific events layered on top.
3on vs once vs off
const emitter = new EventEmitter();
function handler(msg) { console.log("got:", msg); }
emitter.on("msg", handler); // persistent — fires every time
emitter.once("msg", (msg) => { // fires ONCE, then auto-removes itself
console.log("first time only:", msg);
});
emitter.emit("msg", "a"); // both fire
emitter.emit("msg", "b"); // only `handler` fires now
emitter.off("msg", handler); // (alias: removeListener) — must pass the SAME function reference
emitter.emit("msg", "c"); // nothing firesRemoving a listener requires the exact same function reference that was registered — an anonymous inline arrow function passed to .on() can never be .off()'d later, because there's no way to refer back to it.
4Multiple Listeners and Removal at Scale
emitter.removeAllListeners("msg"); // remove every listener for one event
emitter.removeAllListeners(); // remove ALL listeners for ALL events — dangerous in shared code
console.log(emitter.listenerCount("msg")); // how many are currently registered
console.log(emitter.eventNames()); // array of every event name with at least one listener5Error Events Are Special
const emitter = new EventEmitter();
emitter.emit("error", new Error("boom")); // throws and CRASHES the process — no 'error' listener registeredemitter.on("error", (err) => {
console.error("handled:", err.message); // now it's just a normal callback, process survives
});
emitter.emit("error", new Error("boom")); // caught by the listener above, no crashThe "error" event name is hard-coded as special inside EventEmitter: emitting it with zero listeners attached throws the error synchronously out of .emit(), which (if nothing catches it) crashes the process. This is a deliberate design choice — a raised error must not be silently swallowed just because nobody happened to be listening — but it means every long-lived EventEmitter-based object (especially streams and sockets) needs an 'error' listener in production code, full stop.
6Async Work Inside Listeners
emitter.on("data", async (chunk) => {
await process(chunk); // EventEmitter has NO idea this listener is async
});.emit() does not await anything — it calls each listener and moves to the next one immediately, regardless of whether a listener returns a Promise. An async listener that rejects produces an unhandled promise rejection, not a caught error inside the emitter; .emit()'s return value only tells you whether there WAS at least one listener (a boolean), never whether they succeeded.
7emitter.on(...) Chaining and Prepending
emitter.on("x", () => {}).on("y", () => {}); // .on() returns `this`, so calls chain
emitter.prependListener("x", fn); // registers `fn` to run FIRST, ahead of already-registered listenersUnder the Hoodthe Internal _events Map and the Listener Leak Warning▾
_events is a plain object mapping event names to listeners
Every EventEmitter instance holds an internal _events object (not part of the public API, but observable and a real implementation detail worth understanding). Each key is an event name; the value is either:
- a single function reference, if exactly one listener is registered for that event (the common case, kept unwrapped for a small memory/speed win), or
- an array of function references, once a second listener is added for the same event name.
const emitter = new EventEmitter();
emitter.on("x", () => {});
console.log(emitter._events.x); // a single function
emitter.on("x", () => {});
console.log(Array.isArray(emitter._events.x)); // true — promoted to an array once there are 2+.emit(name, ...args) looks up this._events[name]; if it's a single function, it calls it directly; if it's an array, it iterates a copy of that array (so a listener that adds/removes another listener mid-emit doesn't corrupt the in-progress iteration) and calls each one synchronously, in order, with the same arguments.
The maxListeners leak warning is a heuristic, not a hard limit
const emitter = new EventEmitter();
emitter.setMaxListeners(5); // default is 10
for (let i = 0; i < 11; i++) {
emitter.on("tick", () => {}); // triggers a MaxListenersExceededWarning after the 11th
}Every time a listener is added, EventEmitter checks the current count for that event name against maxListeners (default 10, configurable per-instance via .setMaxListeners(n), or globally via EventEmitter.defaultMaxListeners). Exceeding it prints a MaxListenersExceededWarning to stderr — this is not an error and does not stop the listener from being added; it's purely a leak-detection heuristic, because the single most common cause of "too many listeners on one event" in real code is a bug: something (often inside a loop, or on every request in a server) repeatedly calling .on() on the same long-lived emitter without ever removing the old listener, growing unboundedly and leaking memory over the process's lifetime. Legitimately needing more than 10 listeners on one emitter is fine and the limit should just be raised in that case — the warning exists to catch the accidental, ever-growing case.
Why removal requires the exact function reference
Because _events[name] stores actual function references (not names, ids, or wrapped descriptors), .off(name, fn) has to find fn by reference-equality inside that array/slot and splice it out. This is precisely why an anonymous listener can't be removed later — nothing else holds a reference to that same function object to compare against.
⚠️ Common Pitfalls
- Emitting
"error"with no listener attached — crashes the process. Every long-lived emitter (streams, sockets, servers) needs an'error'handler in real code. - Passing a fresh arrow function to
.off(), expecting it to remove a previously.on()'d listener — reference equality fails because it's a different function object, even if the code looks identical. - Registering a new listener inside a hot loop or per-request handler without ever removing it — the classic listener leak; watch for the
MaxListenersExceededWarningas a real signal, not noise to suppress. - Assuming
.emit()waits for async listeners — it doesn't; it fires-and-forgets each listener call, so ordering guarantees and error handling for async work inside a listener are entirely on you. - Calling
removeAllListeners()with no arguments on a shared/library-owned emitter — wipes listeners for every event, including ones other code registered; prefer removing your own specific listener or event name.
📋 Quick Reference
new EventEmitter() / class X extends EventEmitter → the base pattern
.on(name, fn) / .addListener(name, fn) → persistent listener
.once(name, fn) → fires once, then auto-removed
.off(name, fn) / .removeListener(name, fn) → needs the SAME function reference
.emit(name, ...args) → calls listeners SYNCHRONOUSLY, returns boolean (had listeners?)
.removeAllListeners([name]) → strips listeners for one event, or ALL events if omitted
.listenerCount(name) / .eventNames() → introspection
.setMaxListeners(n) / EventEmitter.defaultMaxListeners → leak-detection threshold (default 10)
'error' event → special: throws/crashes if emitted with zero listeners
_events → internal map: function, or array once 2+ listeners🧠 Key takeaways
emit()calls listeners synchronously, in order; it doesn't wait for async listeners.onadds,onceauto-removes after the first call,off/removeListenerremoves (same function reference).- An
'error'event without a listener throws; always handle it. - 'MaxListenersExceededWarning' usually means a listener leak (adding per request without removing).
🎤 Interview Q&A
Q1How does EventEmitter work?core▾
Objects register listeners with on(event, fn) and trigger them with emit(event, ...args); listeners are called synchronously in registration order. Many core modules (streams, http servers) are EventEmitters.
- once()? Registers a listener that runs only for the next emit.
Q2What happens when an 'error' event has no listener?core▾
The emitter throws the error, which crashes the process if uncaught. Always handle error events on streams, sockets and custom emitters.
- How to await a single event?
await events.once(emitter, 'ready').
Q3What's the MaxListenersExceededWarning?core▾
A warning when more than 10 listeners are added for one event, often indicating a leak (adding listeners per request without removing them). Fix the leak rather than raising the limit.
- How do you remove listeners?
off/removeListenerwith the same function reference, or AbortSignal options.
Q4Are EventEmitter listeners async?senior▾
No; emit calls them synchronously. Async listeners' returned promises are ignored, so rejections become unhandled unless you enable captureRejections.
- Implication? A slow synchronous listener blocks the emitter and the event loop.
Q5When would you use EventEmitter vs a message broker?core▾
EventEmitter for in-process decoupling (domain events within one service). A broker for cross-process/service events that need durability, retries and scaling.
- Risk of in-process events? Lost on crash; no retries.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.