The EventEmitter Class
An EventEmitter is the object at the heart of Node.js’s event-driven design: something that can announce "this just happened" and let any number of independent listeners react, without emitter and listener knowing anything about each other. Almost every core API you’ll touch — http.Server, streams, process, child processes — is either an EventEmitter or wraps one internally. Understanding it well means understanding how a huge part of Node actually communicates.
Overview / How it works
Node ships a built-in module, node:events, exporting a class called EventEmitter. It implements the classic observer pattern: an emitter keeps an internal map from event names (strings or Symbols) to arrays of listener functions. Calling .on(name, fn) pushes fn onto that array. Calling .emit(name, ...args) looks up the array for name and calls every listener in it, in the order they were registered, passing along whatever arguments were given to emit().
The detail that trips people up is that emit() is synchronous. It does not schedule anything on the event loop or the microtask queue — it walks the listener array and calls each function immediately, one after another, on the current call stack, and only returns once every listener has returned. This matters in two ways: if a listener throws and nothing catches it, the exception propagates straight out of the emit() call itself; and if a listener does slow synchronous work, it blocks the entire event loop for that duration, exactly like any other blocking code.
Because this pattern is so useful, dozens of core APIs are actual EventEmitters or use one internally: an http.Server emits request, connection, and close; a readable stream emits data, end, and error; process emits exit and uncaughtException; a ChildProcess emits exit and message. When you write class Task extends EventEmitter, your instances get the exact same on / emit / once / off vocabulary — which is why extending it is the standard way to build anything in Node that produces a stream of occurrences over time: a job queue, a parser, a WebSocket wrapper, a state machine.
One event name is special: error. If you call emit('error', err) on an emitter with no error listener registered, Node does not drop it silently — emit() throws err right there, synchronously, as part of that call. If nothing up the call stack catches it, it becomes an uncaught exception and crashes the process. This is deliberate: EventEmitter has no return value worth checking, so error is Node’s way of forcing you to opt in to handling failures instead of letting them vanish.
Every emitter also tracks a max-listener count per event, defaulting to 10 (EventEmitter.defaultMaxListeners), purely as a leak-detection heuristic. Registering an 11th listener for the same event on the same emitter prints a MaxListenersExceededWarning to stderr — not a crash, just a hint that you may be leaking listeners, for example by adding one per incoming HTTP request and never removing it.
Promise-based waiting: events.once() and events.on()
Sometimes you want to await a single future event instead of registering a callback. The node:events module exports a standalone once(emitter, name) function (not the emitter’s own .once() method) that returns a promise resolving with the event’s arguments as an array the first time the event fires. There is also an on(emitter, name) async iterator for consuming a continuous stream of events with for await. These are convenient bridges between the callback-style emitter world and promise/async-await code.
const EventEmitter = require("node:events");
const { once } = require("node:events");
async function main() {
const emitter = new EventEmitter();
setTimeout(() => emitter.emit("ready", "server started"), 100);
const [message] = await once(emitter, "ready");
console.log(message);
}
main();
Output:
server started
The setTimeout fires the ready event 100ms later; await once(emitter, "ready") suspends main() until that happens, then unpacks the single argument from the array once() resolves with.
Syntax
The table below covers the methods you’ll use on virtually every EventEmitter, whether it’s one you create directly or one Node hands you (like a stream or an http.Server).
| Method | Description |
|---|---|
on(name, fn) |
Register fn as a listener for event name. Alias: addListener(). |
once(name, fn) |
Like on(), but the listener is removed automatically right after it fires once. |
emit(name, ...args) |
Synchronously call every listener registered for name, passing args. Returns true if there were listeners, false otherwise. |
off(name, fn) |
Remove one specific listener. Alias: removeListener(). |
removeAllListeners([name]) |
Remove every listener for name, or for every event if no name is given. |
listenerCount(name) |
Number of listeners currently registered for name. |
eventNames() |
Array of every event name that currently has at least one listener. |
setMaxListeners(n) |
Override the default leak-warning threshold (10) for this emitter. |
prependListener(name, fn) |
Like on(), but adds fn to the front of the listener array instead of the back. |
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on(eventName, listener); // register a listener for eventName
emitter.once(eventName, listener); // like on(), but auto-removes after the first call
emitter.emit(eventName, ...args); // call every listener for eventName, synchronously
emitter.off(eventName, listener); // remove one listener (alias: removeListener)
emitter.removeAllListeners(eventName); // remove every listener for eventName
emitter.listenerCount(eventName); // number of listeners currently registered
emitter.eventNames(); // array of event names that currently have listeners
Examples
Example 1: A minimal emitter
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("greet", (name) => {
console.log(`Hello, ${name}!`);
});
emitter.emit("greet", "Ada");
emitter.emit("greet", "Grace");
Output:
Hello, Ada!
Hello, Grace!
Each call to emit("greet", name) immediately invokes the listener with that argument. The listener runs twice because emit() was called twice — a plain on() listener stays registered until you explicitly remove it.
Example 2: once(), listenerCount(), and off()
const EventEmitter = require("node:events");
const orders = new EventEmitter();
function logOrder(id, total) {
console.log(`Order ${id} received, total: $${total.toFixed(2)}`);
}
orders.on("order", logOrder);
orders.once("order", (id) => {
console.log(`(once) First order seen: ${id}`);
});
console.log("Listeners for 'order':", orders.listenerCount("order"));
orders.emit("order", 1001, 49.99);
orders.emit("order", 1002, 12.5);
orders.off("order", logOrder);
orders.emit("order", 1003, 5);
Output:
Listeners for 'order': 2
Order 1001 received, total: $49.99
(once) First order seen: 1001
Order 1002 received, total: $12.50
Two listeners are registered, so listenerCount reports 2. Both fire on the first emit(), but the once() listener removes itself right after, so only logOrder fires on the second. After off("order", logOrder) removes the remaining listener, the third emit() has nothing left to call and produces no output at all — emit() simply returns false.
Example 3: A class that extends EventEmitter
const EventEmitter = require("node:events");
class TaskQueue extends EventEmitter {
constructor() {
super();
this.tasks = [];
}
addTask(task) {
this.tasks.push(task);
}
async run() {
for (const task of this.tasks) {
this.emit("start", task.name);
try {
const result = await task.fn();
this.emit("complete", task.name, result);
} catch (err) {
this.emit("error", err);
}
}
}
}
const queue = new TaskQueue();
queue.on("start", (name) => console.log(`Starting: ${name}`));
queue.on("complete", (name, result) => console.log(`Done: ${name} -> ${result}`));
queue.on("error", (err) => console.error(`Task failed: ${err.message}`));
queue.addTask({
name: "fetch-config",
fn: async () => "config-loaded",
});
queue.addTask({
name: "validate",
fn: async () => {
throw new Error("validation failed");
},
});
queue.run();
Output:
Starting: fetch-config
Done: fetch-config -> config-loaded
Starting: validate
Task failed: validation failed
TaskQueue doesn’t hold an emitter as a property — it is one, because it extends EventEmitter. Because there’s an error listener registered, the thrown error in the second task turns into a handled error event instead of crashing the process. This is the shape most real Node classes that model "a sequence of things happening" take.
How it works step by step (under the hood)
Walking through Example 3’s run() method shows exactly what’s synchronous and what isn’t:
for (const task of this.tasks)starts iterating synchronously, on the initial call torun().this.emit("start", task.name)runs everystartlistener to completion, synchronously, before the next line executes. Theconsole.loginside the listener has already printed by the timeemit()returns.await task.fn()is where control actually leaves the synchronous chain:run()suspends here, the promise fromtask.fn()is left to settle, and the JavaScript engine is free to run other queued work (timers, other I/O callbacks) in the meantime.- Once the awaited promise settles,
run()resumes as a microtask, andthis.emit("complete", ...)orthis.emit("error", ...)fires — again synchronously, running every listener beforerun()continues to the next loop iteration.
The key takeaway: emit() itself never introduces asynchrony. Any asynchrony in a program built on EventEmitter comes from what happens between emits (an await, a setTimeout, an I/O callback), not from emit() itself. Also note that emit() does not wait for a listener that returns a promise — it’s fire-and-forget from the emitter’s point of view. If a listener is async and throws inside an awaited section, that rejection becomes an unhandled promise rejection, not something emit() can catch, since emit() already returned by the time the rejection happens.
Common Mistakes
Mistake 1: No listener for the error event
Emitting error with nothing listening throws synchronously, right at the emit() call site, and crashes the process if nothing up the stack catches it.
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
// No "error" listener registered.
// emit() special-cases "error": with zero listeners it throws
// the Error synchronously, right at this call site.
emitter.emit("error", new Error("Something broke"));
The fix is to always attach an error listener to any emitter you don’t fully control (or one you create yourself and expect to report failures on):
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("error", (err) => {
console.error("Emitter error:", err.message);
});
emitter.emit("error", new Error("Something broke"));
Output:
Emitter error: Something broke
Mistake 2: Registering a new listener every time instead of once
Adding a listener inside a function that runs repeatedly — a request handler, a loop — without ever removing it silently multiplies work and leaks memory. Past 10 listeners for the same event, Node also prints a MaxListenersExceededWarning.
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
function handleRequest(req) {
// BUG: this adds a brand-new listener on every call
// and never removes it.
emitter.on("data", (chunk) => {
console.log(`Handling ${req.id}: ${chunk}`);
});
}
for (let i = 0; i < 15; i++) {
handleRequest({ id: i });
}
emitter.emit("data", "chunk-1");
Each call to handleRequest adds one more closure, so the single emit() at the end triggers 15 separate listeners, all logging the same chunk with different captured req.id values — and a warning fires once the count passes 10. The fix is to register the listener once, outside the per-request code path, and pass request-specific data through emit()'s arguments instead of capturing it in a new closure:
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("data", (chunk, reqId) => {
console.log(`Handling ${reqId}: ${chunk}`);
});
function handleRequest(req, chunk) {
emitter.emit("data", chunk, req.id);
}
for (let i = 0; i < 15; i++) {
handleRequest({ id: i }, "chunk-1");
}
Output:
Handling 0: chunk-1
Handling 1: chunk-1
Handling 2: chunk-1
Handling 3: chunk-1
Handling 4: chunk-1
Handling 5: chunk-1
Handling 6: chunk-1
Handling 7: chunk-1
Handling 8: chunk-1
Handling 9: chunk-1
Handling 10: chunk-1
Handling 11: chunk-1
Handling 12: chunk-1
Handling 13: chunk-1
Handling 14: chunk-1
Best Practices
- Always attach an
errorlistener to any emitter you don't fully control (streams, sockets, child processes) — an unhandlederrorevent crashes the process. - Remove listeners you no longer need with
off(), especially ones added inside request handlers or loops; preferonce()for anything that should only react a single time. - Keep listener functions fast and non-blocking. Because
emit()is synchronous, one slow listener delays every other listener for that event and blocks the whole event loop. - Extend
EventEmitterwhen instances of your class genuinely are the thing producing events (a queue, a parser); use composition (hold an emitter as a property) when events are just an internal implementation detail. - Only call
setMaxListeners(n)to raise the limit when you genuinely expect many listeners on one emitter, and leave a comment explaining why — otherwise treat the warning as a real leak signal. - Use plain lowercase event names (
"data","error","close") to match Node convention, orSymbols when you need names guaranteed not to collide with events from other code. - Reach for
events.once(emitter, name)when you just need to await a single future event instead of wiring up a manual listener and cleanup.
Practice Exercises
- Build a
Stopwatchclass extendingEventEmitterwithstart()andstop()methods. While running, it should emit atickevent once a second with the elapsed milliseconds;stop()should clear the timer and emit a finalstoppedevent with the total elapsed time. Attach listeners that log each tick and the final result. - Create a plain
EventEmitterrepresenting a chat room. Whenever someone posts, emit amessageevent with a{ user, text }payload. Register two independent listeners: one that logs every message to the console, and another that pushes messages into an array acting as chat history. Confirm both listeners fire for every message and the history array grows correctly. - Call
emitter.emit("error", new Error("boom"))on an emitter with noerrorlistener, wrapped in atry/catch. Observe that thecatchblock does catch it, then explain in your own words why (hint: re-read howerroris special-cased in this lesson). Then add anerrorlistener and see how the behavior changes.
Summary
EventEmitter(fromnode:events) implements the observer pattern:on()registers listeners,emit()calls them synchronously, in registration order.- Many core Node APIs — streams,
http,process,child_process— areEventEmitters or wrap one internally. - The
errorevent is special: with no listener,emit("error", ...)throws synchronously at the call site, which crashes the process if uncaught — always attach anerrorlistener. once()auto-removes itself after firing once;off()/removeListener()removes a listener manually. Forgetting to remove listeners you no longer need causes leaks and, past 10 per event by default, aMaxListenersExceededWarning.- Extend
EventEmitterfor your own classes that represent a stream of occurrences over time, such as a queue or parser. - Use the promise-based
events.once(emitter, name)andevents.on(emitter, name)helpers to bridge callback-style events intoasync/awaitcode. - Keep listeners fast —
emit()is synchronous and blocks the event loop until every listener has returned.
