Custom Events and Listeners
Node.js ships with a built-in publish/subscribe system called EventEmitter, and it is one of the most important building blocks in the entire platform. Instead of wiring components together with direct function calls, you emit named events and let any number of independent listeners react to them. This is the same pattern that powers http.Server, streams, process, and countless npm packages under the hood — and you can use it to build your own decoupled, event-driven modules.
Overview: How Custom Events Work in Node.js
At the center of Node’s event system is the EventEmitter class, exported from the core node:events module. An EventEmitter instance keeps an internal map of event names to arrays of listener functions. Calling .on(eventName, listener) pushes a function onto that array. Calling .emit(eventName, ...args) looks up the array for that name and invokes every listener in it, synchronously, in the order they were registered, passing along whatever arguments you gave to emit.
This is a crucial detail that trips up a lot of newcomers: emitting an event is not asynchronous by itself. emit() calls every matching listener immediately, on the current stack, one after another, and does not return until they have all run (or thrown). If a listener does asynchronous work — reads a file, makes an HTTP request, calls setTimeout — that work is scheduled the normal way and the emitter has no idea it is “still busy.” The emitter itself is just a dictionary of callbacks; there is no queueing, buffering, or ordering guarantee across separate emit calls beyond plain synchronous execution.
Many of Node’s own core APIs are built directly on top of EventEmitter. An HTTP server emits "request" and "close". A readable stream emits "data", "end", and "error". The process object emits "exit" and "uncaughtException". When you build your own class that extends EventEmitter, you are using the exact same mechanism — which means anything you learn here also explains how those built-in APIs behave.
One event name is special: "error". If you emit("error", ...) on an emitter that has no listener registered for "error", Node treats it as a serious problem — it throws the error, prints a stack trace, and (in a typical script) crashes the process. This is a deliberate safety mechanism so that errors can never be silently dropped. It also means you should always attach an "error" listener to any emitter that might emit one.
Syntax
The core EventEmitter API is small. The general shape looks like this:
emitter.on(eventName, listener);
emitter.once(eventName, listener);
emitter.emit(eventName, ...args);
emitter.off(eventName, listener); // same as removeListener
- eventName — a string (or symbol) naming the event. Any string works; there is no fixed list except the special
"error"name. - listener — a function invoked with whatever arguments
emitwas called with. Avoid arrow functions if you needthisto refer to the emitter. .on(name, fn)— registersfnto run every timenameis emitted. Returns the emitter, so calls can be chained..once(name, fn)— registersfnto run only on the next emission ofname, then automatically removes itself..emit(name, ...args)— synchronously calls every listener registered fornamewithargs. Returnstrueif there were listeners,falseotherwise..off(name, fn)/.removeListener(name, fn)— removes a specific listener. You need a reference to the exact function that was registered..removeAllListeners([name])— removes every listener for one event, or for all events if no name is given..listenerCount(name)— how many listeners are currently registered for an event.
Examples
Example 1: A basic custom event
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("greet", () => {
console.log("Hello there!");
});
emitter.emit("greet");
Hello there!
This is the whole pattern in miniature: create an emitter, register a listener for a named event with .on(), then trigger it with .emit(). Nothing runs until emit is called — registering a listener just stores the function for later.
Example 2: Passing arguments and using once
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("order", (item, quantity) => {
console.log(`Received order: ${quantity}x ${item}`);
});
emitter.once("order", (item) => {
console.log(`First-time customer bonus applied for ${item}!`);
});
emitter.emit("order", "coffee", 2);
emitter.emit("order", "coffee", 1);
Received order: 2x coffee
First-time customer bonus applied for coffee!
Received order: 1x coffee
Any extra arguments passed to emit are forwarded positionally to every listener — here item and quantity. Listeners fire in registration order, so the on listener runs before the once listener. After the first emission, the once listener is automatically removed, so the second emit only reaches the permanent listener.
Example 3: A realistic emitter class with error handling
const EventEmitter = require("node:events");
class OrderProcessor extends EventEmitter {
process(order) {
if (!order.item || order.quantity <= 0) {
this.emit("error", new Error(`Invalid order: ${JSON.stringify(order)}`));
return;
}
this.emit("processing", order);
setImmediate(() => {
this.emit("completed", { ...order, processedAt: Date.now() });
});
}
}
const processor = new OrderProcessor();
function onProcessing(order) {
console.log(`Processing ${order.quantity}x ${order.item}...`);
}
processor.on("processing", onProcessing);
processor.on("completed", (result) => {
console.log(`Done: ${result.quantity}x ${result.item}`);
processor.off("processing", onProcessing);
});
processor.on("error", (err) => {
console.error(`Order failed: ${err.message}`);
});
processor.process({ item: "keyboard", quantity: 3 });
processor.process({ item: "", quantity: 0 });
Processing 3x keyboard...
Order failed: Invalid order: {"item":"","quantity":0}
Done: 3x keyboard
This is the idiomatic way to build a reusable event-driven module: extend EventEmitter so instances of your class are emitters themselves. Note the output order. The "processing" and "error" events fire synchronously, so both print before anything else runs. The "completed" event is scheduled with setImmediate, which defers it to the check phase of the event loop — after all currently queued synchronous code has finished. That’s why “Done” prints last, even though process() was called for the valid order first. The example also removes the "processing" listener with .off() once it’s no longer needed.
How It Works Step by Step
Walking through what actually happens when you call emitter.emit("name", arg):
- Node looks up the internal listener array for
"name"on that specific emitter instance. - If the array is empty (or doesn’t exist) and the event name is
"error", Node throws the error argument right there, synchronously — if nothing catches it, the process crashes. - If the array is empty for any other event name,
emitsimply returnsfalseand does nothing. - Otherwise, Node iterates the array in registration order and calls each listener with the arguments you passed to
emit, all on the current call stack. - Any listener registered with
.once()is removed from the array immediately before it is invoked, so it can never fire twice, even if it re-emits the same event from inside itself. - If a listener throws synchronously and nothing catches it, the exception propagates out of
emit()to whatever called it — the remaining listeners in the array are not called. - Control returns to whatever code called
emit()only after every listener has run (or one has thrown). If a listener kicks off async work (a promise, a timer, an I/O call), that work continues independently —emitdoes not wait for it.
This last point is why Example 3 prints its lines in the order it does: the synchronous listeners for "processing" and "error" run to completion during the two process() calls, and only afterward does the event loop reach the check phase and run the setImmediate callback that emits "completed".
Common Mistakes
Mistake 1: Emitting "error" with no listener attached
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("data", (chunk) => {
console.log(`Got ${chunk.length} bytes`);
});
// Something goes wrong internally...
emitter.emit("error", new Error("Parse failure"));
Because no listener is registered for "error", Node throws the error instead of quietly passing it around, and an unhandled throw here crashes the entire process. This is intentional: Node refuses to let errors disappear silently. The fix is to always attach an "error" listener on any emitter that might emit one:
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("data", (chunk) => {
console.log(`Got ${chunk.length} bytes`);
});
emitter.on("error", (err) => {
console.error(`Handled: ${err.message}`);
});
emitter.emit("error", new Error("Parse failure"));
Handled: Parse failure
Mistake 2: Re-registering listeners instead of registering once
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
function handleRequest() {
// BUG: this adds a brand new listener on every call
emitter.on("data", (chunk) => {
console.log(`Got ${chunk.length} bytes`);
});
emitter.emit("data", Buffer.from("hi"));
}
for (let i = 0; i < 15; i++) {
handleRequest();
}
Calling .on() inside a function that runs repeatedly (a request handler, a loop, a retry path) attaches a brand new duplicate listener every time instead of reusing one. Past 10 listeners for the same event, Node prints a MaxListenersExceededWarning, warning about a possible EventEmitter memory leak, and each subsequent emit triggers an ever-growing pile of listeners — here, later calls print “Got 2 bytes” many times over for a single emission. The fix is to register the listener once, outside the hot path:
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("data", (chunk) => {
console.log(`Got ${chunk.length} bytes`);
});
function handleRequest() {
emitter.emit("data", Buffer.from("hi"));
}
for (let i = 0; i < 15; i++) {
handleRequest();
}
If an emitter legitimately needs more than 10 listeners for one event, raise the limit explicitly with emitter.setMaxListeners(n) rather than ignoring the warning — the default of 10 exists purely as a leak-detection heuristic, not a hard technical limit.
Best Practices
- Always attach an
"error"listener to anyEventEmitterthat might emit one — an unhandled"error"event crashes the process. - Prefer named function references over inline arrow functions when you will need to remove the listener later with
.off()— you can’t remove an anonymous function you no longer have a reference to. - Use
.once()for events that logically happen only one time (a “ready” or “connected” signal) instead of manually removing a listener from inside itself. - Extend
EventEmitterin a class when you’re building a reusable, stateful module — it’s the idiomatic Node pattern used throughout the core APIs. - Remember that
emit()is synchronous — don’t rely on it to schedule work for “later” unless a listener itself defers withsetImmediate, a promise, or a timer. - Call
.removeAllListeners()or otherwise clean up listeners when an emitter (or the object holding it) is discarded, especially in long-running servers, to avoid leaks. - Document the event names and payload shapes your class emits — unlike a function signature, nothing enforces what arguments an event carries.
- Use
emitter.listenerCount(name)oremitter.eventNames()when debugging unexpected duplicate-firing behavior.
Practice Exercises
- Create an
EventEmitternamedticketQueuethat emits a"ticket"event carrying a ticket number. Register two listeners: one that logs every ticket, and one registered with.once()that only logs a friendly message the first time, e.g. ‘Welcome, you’re first in line!’. - Build a class
Downloaderthat extendsEventEmitterwith astart(url)method. It should emit"progress"a few times with fake percentage values viasetTimeout, then emit"done". Attach listeners that print progress and a final completion message. (Hint: no real network call needed — simulate it with timers.) - Take the leaking-listener example from Common Mistakes and modify it to call
emitter.setMaxListeners(20)before the loop. Run it and confirm the warning no longer appears — then explain, in a comment, why raising the limit is not always the right fix.
Summary
EventEmitterfromnode:eventsis the core publish/subscribe primitive that many Node APIs (HTTP, streams,process) are built on..on()registers a persistent listener,.once()registers a one-shot listener, and.emit()synchronously calls every matching listener in registration order.- Emitting
"error"with no listener attached throws and can crash the process — always handle it. - Extend
EventEmitterin your own classes to build decoupled, event-driven modules the idiomatic Node way. - Watch for listener leaks: registering the same listener repeatedly triggers
MaxListenersExceededWarningand duplicate callback firing. .off()/.removeListener()need the original function reference, which is why named functions beat anonymous ones for listeners you plan to remove.
