Error Events and Listener Leaks

Every EventEmitter in Node.js — including streams, sockets, HTTP servers, and child processes — treats one event name differently from all the others: "error". If an "error" event is emitted and nobody is listening for it, Node does not silently drop it the way it would drop any other unhandled event. Instead it throws the error, which crashes your process unless something higher up catches it. Combined with this, every emitter also watches how many listeners you attach and warns you when that number looks like a bug rather than a design choice — a listener leak. This lesson covers both mechanisms in depth: why they exist, exactly how they behave, and how to write emitters and consumers that don’t fall into either trap.

Overview / How Error Events Work

EventEmitter is Node’s core pub/sub primitive (node:events), and emit() is a completely synchronous, in-process function call — it loops through the listeners registered for an event and calls each one in the order it was added, right there on the call stack. There is nothing magic about most events: if you emit("data") and nobody registered a "data" listener, the call simply does nothing and returns false.

"error" is the one exception, by deliberate design. When emit("error", err) runs and the emitter has zero listeners for "error", Node’s internal emit() implementation throws instead of silently returning. If the value you passed is an Error instance, that same error is thrown; if it isn’t, Node wraps it in a new Error first. Because this throw happens synchronously inside emit(), it propagates up the call stack like any other uncaught exception and, if nothing catches it, brings down the whole Node process with a non-zero exit code. This is intentional: the authors of Node decided that a swallowed error is far more dangerous than a crashed process, because a swallowed error can leave your program running in a corrupted, half-failed state without anyone noticing. A crash is loud and gets fixed; silence gets shipped to production.

This is why every built-in stream, socket, and server in Node — fs.createReadStream(), net.Socket, http.Server, child_process.spawn() — emits "error" rather than throwing directly from an async callback (there’s no synchronous call stack to throw into once you’re inside an I/O callback). Whoever consumes that emitter is contractually expected to attach an "error" listener. Skip it and your program is one failed DNS lookup or one missing file away from an unhandled crash.

Listener Leaks

The second mechanism is unrelated to "error" specifically but is closely tied to how emitters are used correctly: every EventEmitter tracks how many listeners are registered per event name, and by default warns you once a single event name accumulates more than 10 listeners on the same instance. This isn’t a hard limit — your code keeps working past 10 — it is a heuristic memory-leak detector. The most common way real programs leak memory with emitters is by calling .on() inside a function that runs repeatedly (a request handler, a loop, a retry) without ever removing the listener. Each call adds one more closure that Node keeps alive forever, and each of those closures usually holds references to whatever variables were in scope when it was created — request objects, buffers, database connections — so the leak is often much bigger than it first appears. When the threshold is crossed, Node emits a process-level "warning" event with a MaxListenersExceededWarning, which by default is printed to stderr.

Syntax

The relevant parts of the EventEmitter API for error handling and leak prevention:

Method / Property Description
emitter.on(event, listener) Registers a listener that runs every time event fires.
emitter.once(event, listener) Registers a listener that fires at most once, then removes itself automatically.
emitter.off(event, listener) Alias for removeListener; removes one specific listener.
emitter.removeAllListeners([event]) Removes every listener for event, or for all events if omitted.
emitter.emit(event, ...args) Synchronously calls every registered listener for event, in registration order.
emitter.listenerCount(event) Returns how many listeners are currently registered for event.
emitter.eventNames() Returns an array of event names that currently have listeners.
emitter.setMaxListeners(n) Overrides the warning threshold (default 10) for this specific emitter instance.
emitter.getMaxListeners() Returns the current threshold for this emitter.
events.defaultMaxListeners Module-level default (10) applied to every new emitter unless overridden per instance.
events.errorMonitor A special symbol event; listening on it lets you observe "error" emissions without suppressing the default throw-on-no-listener behavior.

Examples

Example 1: A custom emitter with proper error handling

const EventEmitter = require("node:events");

class TaskRunner extends EventEmitter {
  run(task) {
    if (typeof task !== "function") {
      this.emit("error", new TypeError("task must be a function"));
      return;
    }
    try {
      const result = task();
      this.emit("done", result);
    } catch (err) {
      this.emit("error", err);
    }
  }
}

const runner = new TaskRunner();

runner.on("error", (err) => {
  console.error("Task failed:", err.message);
});

runner.on("done", (result) => {
  console.log("Task finished:", result);
});

runner.run(() => 2 + 2);
runner.run("not a function");

Output:

Task finished: 4
Task failed: task must be a function

TaskRunner emits "done" on success and "error" on failure instead of throwing directly, so callers decide how to react. Because an "error" listener is attached before run() is ever called, the second call — which fails validation — reports the failure cleanly instead of crashing the process.

Example 2: What happens with no error listener

const EventEmitter = require("node:events");

const emitter = new EventEmitter();

// No "error" listener registered.
emitter.emit("error", new Error("nobody is listening for this"));

console.log("this line never runs");

Output:

node:events:496
      throw er; // Unhandled 'error' event
      ^

Error: nobody is listening for this
    at Object.<anonymous> ...

Node.js v20.x.x

Because emit("error", ...) finds zero listeners for "error", Node throws that same Error object synchronously inside emit(). Nothing catches it, so it becomes an uncaught exception, Node prints the stack trace, and the process exits with a non-zero code. console.log("this line never runs") genuinely never runs — execution never returns from emit().

Example 3: Triggering a listener-leak warning

const EventEmitter = require("node:events");

const bus = new EventEmitter();

function subscribe() {
  bus.on("data", () => {});
}

for (let i = 0; i < 11; i++) {
  subscribe();
}

console.log(`listeners: ${bus.listenerCount("data")}`);

Output:

(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
listeners: 11

Calling subscribe() eleven times adds eleven separate, permanent listeners for "data" on the same emitter. The moment the count passes 10, Node emits a process-level warning pointing straight at the pattern that usually indicates a leak: a function that keeps calling .on() without ever calling .off().

Example 4: Subscribing and cleaning up correctly

const EventEmitter = require("node:events");

class Ticker extends EventEmitter {
  start(intervalMs) {
    this.timer = setInterval(() => this.emit("tick", Date.now()), intervalMs);
  }
  stop() {
    clearInterval(this.timer);
  }
}

const ticker = new Ticker();

function onTick(timestamp) {
  console.log("tick at", timestamp);
}

ticker.on("tick", onTick);
ticker.start(50);

setTimeout(() => {
  ticker.off("tick", onTick);
  ticker.stop();
  console.log("listeners after cleanup:", ticker.listenerCount("tick"));
}, 120);

Output:

tick at 1730000000010
tick at 1730000000060
tick at 1730000000110
listeners after cleanup: 0

The named function onTick is kept as a reference specifically so it can be passed to ticker.off("tick", onTick) later — an anonymous arrow function could never be removed this way, since off() needs the exact same function reference that was passed to on(). After cleanup, listenerCount confirms nothing was left behind.

Under the Hood: Order of Operations

When you call emitter.emit("error", err), this is what actually happens, in order:

  • Node looks up the internal listener array for the exact string "error" on that emitter instance.
  • If the array is empty (or only contains an errorMonitor listener), Node throws the error value right there on the current call stack — synchronously, before emit() returns to its caller.
  • If there are one or more listeners, Node calls each one synchronously, in the order they were registered with .on()/.once(), passing along any extra arguments from emit().
  • If a listener itself throws, that exception propagates out of emit() the same way any synchronous throw would — remaining listeners for that emit() call are never invoked.
  • Listeners registered with .once() are removed immediately before they run, so re-entrant emits from inside the listener won’t call them again.
  • Only after every listener has returned does emit() itself return (true if there were listeners, false otherwise).

Crucially, all of this happens on one synchronous call stack. If a listener kicks off asynchronous work — a setTimeout, a promise, another I/O call — and that later throws, the throw happens on a fresh call stack with no relationship to the original emit() call. A try/catch wrapped around emit() cannot catch it, because by the time it throws, emit() has already returned.

Common Mistakes

Mistake 1: Consuming a stream without an error listener

const fs = require("node:fs");

const stream = fs.createReadStream("/does/not/exist.txt");

stream.on("data", (chunk) => {
  console.log(chunk.length);
});

If the file doesn’t exist, fs.createReadStream emits "error" asynchronously once the underlying open fails. With no "error" listener, that crashes the process — and it will happen in production the first time a file is missing, permissions are wrong, or a disk fills up. Every stream, socket, and server needs an error handler, full stop:

const fs = require("node:fs");

const stream = fs.createReadStream("/does/not/exist.txt");

stream.on("error", (err) => {
  console.error("Failed to read file:", err.message);
});

stream.on("data", (chunk) => {
  console.log(chunk.length);
});

Mistake 2: Registering listeners inside a hot path

const EventEmitter = require("node:events");
const http = require("node:http");

const bus = new EventEmitter();

const server = http.createServer((req, res) => {
  bus.on("log", (msg) => console.log(msg));
  bus.emit("log", `request: ${req.url}`);
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("ok");
});

server.listen(3000);

Every incoming request adds a brand-new, permanent "log" listener to bus that is never removed. After 10 requests you get a MaxListenersExceededWarning; after ten thousand requests you have ten thousand closures pinned in memory, each one slowing down every future bus.emit("log", ...) a little more. The listener belongs outside the hot path, registered once:

const EventEmitter = require("node:events");
const http = require("node:http");

const bus = new EventEmitter();

bus.on("log", (msg) => console.log(msg));

const server = http.createServer((req, res) => {
  bus.emit("log", `request: ${req.url}`);
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("ok");
});

server.listen(3000);

Mistake 3: Expecting try/catch to catch an async listener error

const EventEmitter = require("node:events");

const emitter = new EventEmitter();

emitter.on("work", () => {
  setTimeout(() => {
    throw new Error("boom, but too late to catch");
  }, 10);
});

try {
  emitter.emit("work");
  console.log("emit returned normally");
} catch (err) {
  console.error("caught:", err.message);
}

"emit returned normally" prints immediately, and the catch block never runs. Ten milliseconds later the setTimeout callback throws on its own call stack, well after emit() — and the surrounding try/catch — have already finished, so it becomes an uncaught exception instead. Errors that surface asynchronously inside a listener must be handled where they actually occur (their own try/catch, a rejected promise, or by emitting "error" yourself), never by wrapping the original emit() call.

Best Practices

  • Always attach an "error" listener to any emitter you create or consume — streams, sockets, servers, and your own custom classes alike.
  • Treat a MaxListenersExceededWarning as a real leak until proven otherwise; only call setMaxListeners() once you’ve confirmed the listeners are intentional and bounded.
  • Keep a reference to any listener function you’ll need to remove later — anonymous arrow functions can’t be passed to off()/removeListener().
  • Never call .on() inside a function that runs repeatedly (a request handler, a render loop, a retry) unless you also remove the listener when it’s no longer needed.
  • Prefer .once() over .on() plus a manual removal when a listener genuinely only needs to fire one time.
  • Use events.errorMonitor when you want to log or observe errors globally without disturbing the normal throw-on-no-listener contract for the actual "error" listeners.
  • Reach for process.on("uncaughtException") and process.on("unhandledRejection") only as a last-resort safety net for logging before exiting — not as routine control flow for expected errors.
  • For a single async operation, prefer async/await with try/catch over building a custom emitter — reach for "error" events when you’re modeling something long-lived (a connection, a stream, a background worker).

Practice Exercises

  • Create a class Downloader extends EventEmitter with a fetchAll(urls) method that emits "progress" after each item and "error" if a URL is not a string. Verify the process crashes if you forget to attach an "error" listener, then fix it.
  • Write a small pub/sub module where subscribe(emitter, event, handler) returns an unsubscribe() function that calls emitter.off(event, handler). Prove it works by checking listenerCount() before and after calling unsubscribe().
  • Deliberately trigger a MaxListenersExceededWarning by adding 12 listeners for the same event inside a loop, capture the warning with process.on("warning", ...), and print a custom message identifying which event leaked.

Summary

  • The "error" event is special-cased: emitting it with zero listeners throws the error synchronously and crashes the process if nothing catches it.
  • This fail-fast design exists so errors can never be silently dropped — every stream, socket, and server relies on it, so they all need an "error" listener attached.
  • Every emitter warns once more than 10 listeners are registered for the same event on the same instance — a heuristic for catching listener leaks, adjustable with setMaxListeners().
  • The classic leak is calling .on() inside code that runs repeatedly without a matching .off(); keep a reference to the listener function so it can be removed later.
  • emit() is fully synchronous — errors thrown from asynchronous work inside a listener (timers, promises, I/O) cannot be caught by a try/catch wrapped around the original emit() call.