Node.js Events
Node.js is built around an event-driven architecture: instead of constantly asking "is this done yet?", your code registers a listener and gets notified the instant something happens — a file finishes reading, an HTTP request arrives, a socket receives data. Almost every asynchronous or long-lived object in Node’s standard library (streams, servers, child processes) is built on top of a single class called EventEmitter, exported from the built-in node:events module. Once you understand EventEmitter directly, you can read Node’s core APIs fluently and build your own event-driven components — loggers, queues, notifiers — the exact same way Node itself is built.
Overview: How Events Work in Node.js
Many other languages model asynchronous notifications with callbacks tied to a single operation. Node generalizes this into a publish/subscribe pattern: an object (an emitter) can fire named events, and any number of interested parties can listen for those events and react. This is conceptually similar to DOM events in the browser (addEventListener), but Node’s version is a plain JavaScript class you can use anywhere — not just for I/O.
EventEmitter is the foundation
node:events exports a class, EventEmitter. You create an instance, register listener functions for named events with .on(), and trigger those listeners with .emit(). Internally, the emitter keeps a map from event name to an array of listener functions. Calling .emit("name", ...args) looks up that array and calls each function in the order it was registered, passing along whatever arguments you gave emit.
emit() is synchronous
This is the detail that trips people up most: emit() does not schedule anything on the event loop. It calls every registered listener immediately, synchronously, in registration order, and only returns after the last listener has returned. If a listener throws, and nothing catches it, the exception propagates out of the emit() call itself. The asynchronous behavior you associate with events (like an HTTP server "waiting" for a request) comes from when something decides to call emit — usually from a callback that libuv invokes once real I/O completes — not from emit itself being async.
The special "error" event
Node treats the string "error" as a special event name. If you emit("error", ...) on an emitter that has no listener registered for "error", Node throws that error as an uncaught exception and, by default, crashes the process. This is intentional: it forces you to acknowledge failure paths on anything event-based (streams, sockets, child processes) instead of silently swallowing them. Always attach an "error" listener to any emitter that might report one.
Modern helpers: once() and on() from node:events
Alongside the class, the node:events module exports standalone utility functions. events.once(emitter, name) returns a Promise that resolves with the event’s arguments the next time that event fires — handy for turning a single event into something you can await. events.on(emitter, name) (Node 13.6+) returns an async iterable you can loop over with for await to consume a stream of events one at a time. These don’t replace .on() for ongoing listening, but they make one-off or sequential event consumption much cleaner than nesting callbacks.
Syntax
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on(eventName, listenerFn); // subscribe (runs every time)
emitter.once(eventName, listenerFn); // subscribe (runs only once)
emitter.emit(eventName, ...args); // fire the event, synchronously
emitter.off(eventName, listenerFn); // unsubscribe (alias: removeListener)
| Method | Purpose |
|---|---|
on(name, fn) |
Register a listener that runs every time name is emitted. |
once(name, fn) |
Register a listener that auto-removes itself after firing one time. |
emit(name, ...args) |
Synchronously call all listeners for name with the given arguments. |
off(name, fn) / removeListener(name, fn) |
Remove a specific listener function. |
removeAllListeners([name]) |
Remove all listeners, optionally for one event only. |
listenerCount(name) |
How many listeners are registered for an event. |
eventNames() |
Array of event names that currently have listeners. |
setMaxListeners(n) |
Raise the default warning threshold (10) for one emitter. |
Examples
1. A basic emitter
const EventEmitter = require("node:events");
const emitter = new EventEmitter();
emitter.on("greet", (name) => {
console.log(`Hello, ${name}!`);
});
emitter.emit("greet", "Ada");
Hello, Ada!
Registering a listener with .on() and firing it with .emit() is the entire core API. emit forwards every argument after the event name straight through to the listener.
2. Multiple listeners on one event
const EventEmitter = require("node:events");
const orders = new EventEmitter();
orders.on("order:placed", (orderId, total) => {
console.log(`Order ${orderId} received, total $${total}`);
});
orders.on("order:placed", (orderId) => {
console.log(`Sending confirmation email for order ${orderId}`);
});
orders.emit("order:placed", "A1001", 49.99);
Order A1001 received, total $49.99
Sending confirmation email for order A1001
Both listeners run, in the order they were registered, before emit returns. Namespacing the event name as "order:placed" (a common convention) keeps a busy emitter’s event names readable.
3. A class that extends EventEmitter
const EventEmitter = require("node:events");
class Ticker extends EventEmitter {
start() {
let count = 0;
this.timer = setInterval(() => {
count += 1;
this.emit("tick", count);
if (count === 3) {
this.emit("done");
}
}, 100);
}
stop() {
clearInterval(this.timer);
}
}
const ticker = new Ticker();
ticker.on("tick", (count) => {
console.log(`Tick #${count}`);
});
ticker.once("done", () => {
console.log("Ticker finished");
ticker.stop();
});
ticker.on("error", (err) => {
console.error("Ticker error:", err.message);
});
ticker.start();
Tick #1
Tick #2
Tick #3
Ticker finished
Extending EventEmitter is how most of Node’s own classes (http.Server, streams, ChildProcess) are built — your class inherits on, once, and emit for free and emits events at whatever moments matter to it. Notice once() for "done": the listener fires a single time and is then automatically removed, even though the interval kept running until stop() cleared it.
4. Waiting for a single event with events.once()
const EventEmitter = require("node:events");
const { once } = require("node:events");
async function main() {
const emitter = new EventEmitter();
setTimeout(() => {
emitter.emit("ready", { status: "ok" });
}, 50);
const [result] = await once(emitter, "ready");
console.log("Ready event received:", result.status);
}
main();
Ready event received: ok
The standalone once() function (not to be confused with the .once() method) turns a single future event into an awaitable Promise. It resolves with an array of whatever arguments the event was emitted with — here, one object, so it’s destructured as [result]. This is the cleanest way to bridge event-based code into async/await flow.
How It Works Step by Step
Walking through Example 3 in order:
- 1.
new Ticker()constructs the object;EventEmitter‘s constructor initializes its internal listener map. - 2. The three
.on()/.once()calls push functions into that map under the keys"tick","done", and"error". No code has run yet — registering a listener does nothing but store a reference. - 3.
ticker.start()callssetInterval, which hands a timer to libuv and returns immediately.start()itself finishes synchronously; nothing has ticked yet. - 4. Roughly every 100ms, the event loop’s timers phase runs the interval callback. That callback calls
this.emit("tick", count), which synchronously invokes every"tick"listener — here, just the one that logs. - 5. On the third tick, the same callback also calls
this.emit("done"). Theoncelistener runs, logs, callsticker.stop()to clear the interval, and then removes itself from the emitter’s internal map so it can never fire again. - 6. Because an
"error"listener was registered up front, if anything inside the interval callback ever emitted"error", it would be handled gracefully instead of crashing the process.
Common Mistakes
Mistake 1: Emitting "error" with no listener
If nothing is listening for "error", Node re-throws it and crashes the process:
const EventEmitter = require("node:events");
const source = new EventEmitter();
// No listener for "error" registered — this crashes the process!
source.emit("error", new Error("Something broke"));
Always add an "error" listener on any emitter, stream, or socket that can report failures:
const EventEmitter = require("node:events");
const source = new EventEmitter();
source.on("error", (err) => {
console.error("Handled:", err.message);
});
source.emit("error", new Error("Something broke"));
Handled: Something broke
Mistake 2: Registering listeners inside a loop or a repeatedly-called function
Adding a new listener every time a function runs, instead of once up front, leaks memory and triggers Node’s MaxListenersExceededWarning:
const EventEmitter = require("node:events");
const bus = new EventEmitter();
function handleRequest(req) {
// Registers a brand new listener every call — it is never removed
bus.on("data", (chunk) => {
console.log(`Processing ${chunk} for request ${req.id}`);
});
}
for (let i = 0; i < 15; i++) {
handleRequest({ id: i });
}
Register the listener once, outside the hot path, and pass whatever per-call data you need as arguments to emit instead of closing over it in a fresh function:
const EventEmitter = require("node:events");
const bus = new EventEmitter();
bus.on("data", (chunk, requestId) => {
console.log(`Processing ${chunk} for request ${requestId}`);
});
for (let i = 0; i < 15; i++) {
bus.emit("data", "payload", i);
}
By default, EventEmitter warns after 10 listeners on the same event name for the same emitter — that limit exists specifically to catch accidental leaks like this one. If an emitter genuinely needs more listeners by design, raise the limit deliberately with emitter.setMaxListeners(n) rather than ignoring the warning.
Best Practices
- Always attach an
"error"listener to any emitter that might report failures — an unhandled"error"event crashes the process. - Use
.once()instead of.on()for events that should only ever fire and be handled one time (initialization, a single response). - Remove listeners you no longer need with
.off()/.removeListener(), especially on long-lived emitters, to avoid memory leaks. - Prefer
events.once()orevents.on()(async iterator) when you want to consume events withasync/awaitinstead of nested callbacks. - Namespace event names for busy emitters (e.g.
"order:placed","order:cancelled") so they stay readable. - Keep listener functions fast and synchronous where possible; if a listener needs to do heavy async work, have it kick that work off rather than blocking other listeners.
- Don’t rely on listener execution order across unrelated concerns — if two listeners must run in a strict sequence, make that dependency explicit in your code rather than depending on registration order.
- Raise the max-listener limit with
setMaxListeners()only when the higher count is intentional, not to silence a real leak.
Practice Exercises
- 1. Create an
EventEmitterinstance calledloggerwith three listeners for"info","warn", and"error"events, each printing a differently formatted message. Emit one event of each kind. - 2. Write a
Countdownclass that extendsEventEmitter. It should take a starting number, emit a"tick"event once per second with the current value, and emit an"end"event (usingonceinternally is fine) when it reaches zero, stopping its timer. - 3. Using
events.once()fromnode:events, write anasyncfunction that starts asetTimeout-based emitter, awaits a"finished"event, and logs the payload it receives. Compare how this reads versus registering a plain.on()callback.
Summary
EventEmitter, fromnode:events, is the pattern behind almost every async and long-lived Node.js API..on(name, fn)subscribes,.emit(name, ...args)fires listeners synchronously in registration order, and.once(name, fn)auto-unsubscribes after one call.- Emitting
"error"with no listener attached throws and crashes the process — always handle it. - Classes commonly
extend EventEmitterto gain event capabilities directly, as Node’s ownhttp.Serverand streams do. - The standalone
events.once()andevents.on()helpers bridge events intoasync/awaitcode cleanly. - Watch for listener leaks: registering a new listener inside a loop or repeatedly-called function instead of once up front triggers Node’s max-listeners warning and wastes memory.
