Skip to content

EventEmitter

The backbone of Node’s event-driven model

Section titled “The backbone of Node’s event-driven model”

EventEmitter (from node:events) is the class that powers virtually every I/O object in Node.js. The HTTP server, TCP sockets, file streams, child processes, and many third-party libraries all extend EventEmitter. Understanding it means understanding how Node communicates between components.

Node.js
Output (verified on Node 24)
Hello, Alice!
Hello, Bob!
message: first

once registers a listener that automatically removes itself after the first emission.

Node.js
Output (verified on Node 24)
Connected! (fires once only)
listener count: 0

Extend EventEmitter to give your own classes event-driven capabilities.

Node.js
Output (verified on Node 24)
loaded item: alpha
loaded item: beta
loaded item: gamma
all done! total: 3 items
items: alpha, beta, gamma

off is an alias for removeListener added in Node 10. listenerCount lets you inspect how many listeners are registered for an event — useful for detecting potential memory leaks.

Node.js
Output (verified on Node 24)
count before off: 2
count after off: 1
handler2

EventEmitter defaults to a maximum of 10 listeners per event (defaultMaxListeners). This is not a hard limit — it’s a heuristic tripwire: the #1 EventEmitter bug in AI-generated code is a listener registered inside a loop, a request handler, or a React-style re-render, which silently adds a new listener on every call instead of reusing one. Left unnoticed, that’s an unbounded memory leak.

When the count exceeds the max, Node emits a process warning — normally printed to stderr only. To surface it in a script’s own output (and to handle it programmatically, e.g. shipping it to your logs), listen for process.on('warning', ...):

Node.js
Output (verified on Node 24)
default max listeners: 10
listener count: 11
warning name: MaxListenersExceededWarning
warning message: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

If 10 listeners genuinely is too few for a legitimate use case (e.g. a shared event bus with many independent subscribers), raise the limit explicitly with emitter.setMaxListeners(n) — that documents the decision instead of silently outgrowing the default.

events.on() and events.once() — async iteration and promises

Section titled “events.on() and events.once() — async iteration and promises”

events.once(emitter, name) returns a Promise that resolves with the event’s arguments on the first emission — a one-shot alternative to emitter.once() with a callback. events.on(emitter, name) returns an async iterable that yields every emission, for use with for await:

Node.js
Output (verified on Node 24)
once() resolved with: [ 'server' ]
tick: 1
tick: 2
tick: 3
finished iterating ticks

captureRejections — routing async listener errors to ‘error’

Section titled “captureRejections — routing async listener errors to ‘error’”

An async listener that throws produces a rejected promise that EventEmitter does not await — by default that becomes an unhandled rejection, which crashes the process on modern Node (default --unhandled-rejections=throw). Passing captureRejections: true to the constructor routes that rejection to the emitter’s 'error' event instead, where you can handle it like any other error:

Node.js
Output (verified on Node 24)
emit() returned -- the throw happens later, on the microtask queue
caught via error event: async listener failed
done

'error' is a special event name in EventEmitter: if it is emitted and no listener is registered for it, Node throws the error instead of silently dropping it, crashing the process. This is deliberate — it is the one event you are not allowed to ignore by accident.

Node.js
Output (verified on Node 24)
about to emit error with no listener...
flowchart TB
  Loop["listener registered inside a loop / request handler"] -->|"each call adds a NEW listener"| Count["listenerCount keeps growing"]
  Count -->|"exceeds getMaxListeners() (default 10)"| Warn["MaxListenersExceededWarning"]
  Emit["emitter.emit('error', err)"] -->|"listener registered?"| Handled["'error' handler runs — process keeps going"]
  Emit -->|"no listener registered"| Crash["Node throws — process crashes"]
  Async["async listener throws"] -->|"captureRejections: false (default)"| Unhandled["unhandled rejection — crash"]
  Async -->|"captureRejections: true"| Handled
a growing listener count trips a warning; an unhandled 'error' event crashes the process; captureRejections routes async listener failures the same way
ChoiceBenefitCost
emitter.on() callback styleWorks everywhere, zero dependencies, synchronous and predictableEasy to leak — re-registering inside a loop/handler is a silent bug
events.once(emitter, name) (promise)Reads like any other awaited call, composes with Promise.race/AbortSignalOnly fires once — not a replacement for a recurring on() listener
events.on(emitter, name) (async iterator)for await reads top-to-bottom like a stream; backpressure-friendlyNo automatic end — you must break yourself or rely on an 'end'/'close' event
captureRejections: trueAsync listener errors become ordinary 'error' events instead of crashesOff by default — every emitter you construct must opt in individually
  • “EventEmitter listeners run asynchronously, like a DOM event” — fact: .emit() calls every listener synchronously, in registration order, before emit() returns.
  • “MaxListenersExceededWarning means something is broken” — fact: it’s a heuristic warning, not an error — the code keeps running. But it correctly flags the single most common EventEmitter bug: a listener added on every call to a function instead of once.
  • “An unhandled 'error' event just gets ignored, like any other unlistened event” — fact: 'error' is special-cased — with zero listeners it is thrown, crashing the process.
  • “An async listener’s thrown error is caught by emitter.on('error', ...) automatically” — fact: only if the emitter was constructed with captureRejections: true. Otherwise the rejection is unhandled and crashes the process independently of any 'error' listener.
  • Is .on() called inside a loop, a request handler, a class constructor invoked per-request, or a re-render path — anywhere that runs more than once per process lifetime? Each call adds a brand-new listener that is never removed.
  • Does anything that extends or wraps EventEmitter (a custom class, a socket, a stream) have zero 'error' listeners anywhere in the call chain? One unhandled 'error' emission crashes the whole process.
  • Is an async function passed directly to .on() without captureRejections: true on the emitter and without an internal try/catch? Its thrown errors become unhandled rejections, not caught errors.
  • Does code call events.on(emitter, name) and iterate with for await but never break or rely on an end condition? That’s a listener that never gets cleaned up, functionally the same leak as forgetting removeListener.

🐛 Spot the bug

const { EventEmitter } = require('node:events');
const http = require('node:http');
var bus = new EventEmitter();
var server = http.createServer(function(req, res) {
// registers a new listener on every single request
bus.on('request-logged', function(url) {
console.log('logged:', url);
});
bus.emit('request-logged', req.url);
res.end('ok');
});
Show the answer

bus.on('request-logged', ...) is called inside the request handler, so a brand-new listener is registered on every incoming request and never removed. After 10 requests, Node prints MaxListenersExceededWarning; after thousands, the process leaks memory — every one of those listener closures stays alive, and every future emit('request-logged', ...) now runs all of them, multiplying the work per request. The fix is to register the listener once, outside the request handler, before server.listen() is called — the handler should only call bus.emit(...), never bus.on(...).

What is the key difference between emitter.on() and emitter.once()?
When does an EventEmitter listener run relative to emitter.emit()?
Which Node.js built-in classes extend EventEmitter?
What happens when this runs? const { EventEmitter } = require('node:events'); var e = new EventEmitter(); e.emit('error', new Error('boom')); console.log('after emit');
A function registers bus.on('saved', handler) every time it runs, and the function is called once per HTTP request. What is the observable symptom after ~10+ requests?
An EventEmitter is constructed as new EventEmitter({ captureRejections: true }). What changes about a listener registered as emitter.on('work', async () => { throw new Error('x') })?