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.
Basic usage: on, emit, removeListener
Section titled “Basic usage: on, emit, removeListener”Output (verified on Node 24)
Hello, Alice!
Hello, Bob!
message: firstonce — fire exactly one time
Section titled “once — fire exactly one time”once registers a listener that automatically removes itself after the first emission.
Output (verified on Node 24)
Connected! (fires once only)
listener count: 0Building a custom EventEmitter class
Section titled “Building a custom EventEmitter class”Extend EventEmitter to give your own classes event-driven capabilities.
Output (verified on Node 24)
loaded item: alpha
loaded item: beta
loaded item: gamma
all done! total: 3 items
items: alpha, beta, gammaListener count and off()
Section titled “Listener count and off()”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.
Output (verified on Node 24)
count before off: 2
count after off: 1
handler2The listener leak warning
Section titled “The listener leak warning”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', ...):
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 limitIf 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:
Output (verified on Node 24)
once() resolved with: [ 'server' ]
tick: 1
tick: 2
tick: 3
finished iterating tickscaptureRejections — 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:
Output (verified on Node 24)
emit() returned -- the throw happens later, on the microtask queue
caught via error event: async listener failed
doneAn unhandled ‘error’ event throws
Section titled “An unhandled ‘error’ event throws”'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.
Output (verified on Node 24)
about to emit error with no listener...Mental Model
Section titled “Mental Model”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 Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
emitter.on() callback style | Works everywhere, zero dependencies, synchronous and predictable | Easy 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/AbortSignal | Only 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-friendly | No automatic end — you must break yourself or rely on an 'end'/'close' event |
captureRejections: true | Async listener errors become ordinary 'error' events instead of crashes | Off by default — every emitter you construct must opt in individually |
Common Misconceptions
Section titled “Common Misconceptions”- “EventEmitter listeners run asynchronously, like a DOM event” — fact:
.emit()calls every listener synchronously, in registration order, beforeemit()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
EventEmitterbug: 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
asynclistener’s thrown error is caught byemitter.on('error', ...)automatically” — fact: only if the emitter was constructed withcaptureRejections: true. Otherwise the rejection is unhandled and crashes the process independently of any'error'listener.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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
asyncfunction passed directly to.on()withoutcaptureRejections: trueon the emitter and without an internaltry/catch? Its thrown errors become unhandled rejections, not caught errors. - Does code call
events.on(emitter, name)and iterate withfor awaitbut neverbreakor rely on an end condition? That’s a listener that never gets cleaned up, functionally the same leak as forgettingremoveListener.
🐛 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 requestbus.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(...).