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() calls setInterval, 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"). The once listener runs, logs, calls ticker.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() or events.on() (async iterator) when you want to consume events with async/await instead 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 EventEmitter instance called logger with three listeners for "info", "warn", and "error" events, each printing a differently formatted message. Emit one event of each kind.
    • 2. Write a Countdown class that extends EventEmitter. It should take a starting number, emit a "tick" event once per second with the current value, and emit an "end" event (using once internally is fine) when it reaches zero, stopping its timer.
    • 3. Using events.once() from node:events, write an async function that starts a setTimeout-based emitter, awaits a "finished" event, and logs the payload it receives. Compare how this reads versus registering a plain .on() callback.

    Summary

    • EventEmitter, from node: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 EventEmitter to gain event capabilities directly, as Node’s own http.Server and streams do.
    • The standalone events.once() and events.on() helpers bridge events into async/await code 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.