Concept
The beginner framing: a lot of things happen in Node.js, a file finishes reading, a socket receives data, a server gets a new connection, and code needs a way to react to those things happening, without polling or blocking to wait for them.
The precise mental model: EventEmitter is Node's core pub/sub pattern, an object that lets you register listeners for named events (.on(name, fn)) and later trigger all of them (.emit(name, ...args)), synchronously, in registration order. It's not just a utility class off in a corner, it's the foundation underneath streams, HTTP servers, and even process itself, all of which extend EventEmitter.
const { EventEmitter } = require("events");
class OrderProcessor extends EventEmitter {
process(order) {
// ... do work ...
this.emit("processed", order);
}
}
const processor = new OrderProcessor();
processor.on("processed", (order) => console.log(`Order ${order.id} done`));
processor.process({ id: 42 });emit() calls every registered listener synchronously and immediately, it does not queue anything or defer to a future tick. If a listener throws, and nothing catches it, that exception propagates synchronously out of the emit() call itself.
The 'error' event is special-cased
const emitter = new EventEmitter();
emitter.emit("error", new Error("something broke"));
// 💥 CRASHES the entire process, "Unhandled 'error' event"
// ...UNLESS at least one listener is registered for 'error'This is genuinely different from every other event name. If 'error' is emitted and there is no listener for it, Node treats it as an uncaught exception and crashes the process. Every other event name, emitted with no listeners, is a silent no-op. This is one narrow instance of a broader distinction, how Node surfaces different kinds of failures differently depending on the mechanism, covered in full in Error Handling & Async Context.
emitter.on("error", (err) => console.error("handled:", err.message));
emitter.emit("error", new Error("something broke")); // now safely handledThe max-listeners warning: a memory-leak detector
const emitter = new EventEmitter();
for (let i = 0; i < 11; i++) {
emitter.on("data", () => {});
}
// (node:12345) MaxListenersExceededWarning: Possible EventEmitter memory
// leak detected. 11 data listeners added. Use emitter.setMaxListeners()
// to increase limitThe default limit is 10 listeners per event name, per emitter instance. This isn't a hard cap, the 11th listener still gets attached and still works, it's a warning, because the far more common cause of hitting this limit isn't "legitimately needing 11 listeners," it's a bug: code that attaches a new listener on every request, every loop iteration, or every reconnect attempt, instead of attaching one listener once.
Try It
Predict what happens before checking the solution.
const { EventEmitter } = require("events");
const emitter = new EventEmitter();
emitter.emit("greet", "hello"); // emitted BEFORE any listener is attached
emitter.on("greet", (msg) => console.log("received:", msg));
emitter.emit("greet", "world");What gets logged?
Solution
Only received: world. The first emit("greet", "hello") happens before any listener is registered, since emit() is purely synchronous and doesn't queue anything for later, that emission has no listeners to call and is simply a no-op; that data is gone. Only the second emit, which happens after .on() has registered a listener, actually triggers anything.
Implement It Yourself
Build a minimal version of EventEmitter's core mechanism, including the special-cased 'error' behavior:
class MiniEmitter {
#listeners = new Map(); // eventName -> Array<fn>
on(eventName, fn) {
if (!this.#listeners.has(eventName)) this.#listeners.set(eventName, []);
this.#listeners.get(eventName).push(fn);
return this;
}
emit(eventName, ...args) {
const fns = this.#listeners.get(eventName);
if (eventName === "error" && (!fns ||
This captures the two defining behaviors: synchronous, immediate dispatch to registered listeners, and the special-cased throw when 'error' has no listeners.
Under the Hood
EventEmitter is the mechanism underneath both Streams & Buffers' 'data'/'end'/'drain' events and process's own 'SIGTERM'/'exit' events (see Environment, Config & Process Management), every one of those is just .emit() being called internally by Node's own code, dispatching to whatever listeners your application registered with .on().
Common Mistakes
1. Emitting 'error' with no listener attached
const emitter = new EventEmitter();
someAsyncSetup(emitter); // internally does emitter.emit("error", err) on failure
// if nothing ever called emitter.on("error", ...), the process CRASHESAny code that might emit 'error' needs at least one 'error' listener registered, or a single unexpected failure takes down the whole process.
2. Attaching a new listener inside a request handler or loop
app.get("/data", (req, res) => {
emitter.on("update", (data) => res.json(data)); // ❌ NEW listener every request, NEVER removed
});Each request permanently adds another listener that's never cleaned up, eventually triggering the MaxListenersExceededWarning, and worse, a genuine memory leak as old, orphaned listeners (and anything they close over) never get garbage collected.
3. Assuming emit() is asynchronous
emitter.emit("done");
console.log("this runs AFTER all 'done' listeners have already finished");emit() calls all listeners synchronously before returning, there's no implicit deferral, unlike scheduling something with setTimeout or a promise.
Best Practices
- Always attach an
'error'listener on anyEventEmitterthat might emit one, even just.on("error", (err) => logger.error(err)), to prevent an unhandled'error'from crashing the whole process. - Attach long-lived listeners once, typically at setup time, not inside request handlers or loops, use
.once()for listeners that should only fire a single time, and explicitly.off()/.removeListener()listeners that are no longer needed. - Raise
setMaxListeners()deliberately, not reflexively, if you genuinely need more than 10 listeners on one event (e.g., a shared event bus with many legitimate subscribers), raise the limit explicitly; don't raise it as a reflex to silence a warning that's actually flagging a real leak.
Performance Tips
- A silently accumulating listener leak (from mistake #2 above) doesn't just risk hitting the warning threshold, every additional never-removed listener also means more synchronous work done on every future
emit()call, since all of them run on every emission. emit()'s fully synchronous dispatch means a slow listener directly delays every listener registered after it, and delays whatever code calledemit()from continuing, there's no parallelism between listeners for the same event.
