EventEmitter is underneath most of Node’s API: HTTP servers and requests, streams, sockets, child processes, process itself. It looks like an asynchronous mechanism, because you register a callback and it gets called “later”. It isn’t. emit() is a plain synchronous loop over an array of functions, and most event-related bugs come from forgetting that. The examples here ran on Node 22.23.
emit() is a synchronous loop
Here’s an order-processing emitter with four listeners added in different ways:
import { EventEmitter } from 'node:events';
const orders = new EventEmitter();
orders.on('placed', (o) => console.log('1 audit', o.id));
orders.on('placed', (o) => console.log('2 email', o.id));
orders.prependListener('placed', (o) => console.log('0 validate', o.id));
orders.once('placed', (o) => console.log('3 first-order bonus', o.id));
console.log('before emit');
const had = orders.emit('placed', { id: 'A-1' });
console.log('after emit, had listeners:', had);
orders.emit('placed', { id: 'A-2' });before emit
0 validate A-1
1 audit A-1
2 email A-1
3 first-order bonus A-1
after emit, had listeners: true
0 validate A-2
1 audit A-2
2 email A-2Every listener ran between before emit and after emit. Nothing was queued, no tick passed. The rules visible here are the whole ordering model:
- Listeners run in the order they were added.
onand its aliasaddListenerappend. prependListener(andprependOnceListener) put a listener at the front. Use it sparingly; code that depends on running first is fragile, since someone else can prepend too.oncelisteners are removed just before they’re called, so the bonus ran forA-1and not forA-2.emitreturnstrueif there were any listeners andfalseotherwise. That’s occasionally useful for logging events nobody handled.
Because delivery is synchronous, a slow listener delays the code that called emit. If a request handler emits order.placed and the email listener does 40 ms of template rendering, the handler is 40 ms slower. Listeners that start asynchronous work (send an HTTP request, write to a queue) return immediately, which is fine, but then the emitter has no idea whether that work succeeded. We’ll come back to that.
There’s one more detail about the loop. When emit starts, it copies the listener array. A listener that removes another listener during the emit doesn’t stop it from running this time:
const e2 = new EventEmitter();
function b() { console.log('b runs'); }
e2.on('tick', () => { console.log('a runs, removing b'); e2.off('tick', b); });
e2.on('tick', b);
e2.emit('tick');
e2.emit('tick');a runs, removing b
b runs
a runs, removing bb still ran on the first emit and disappeared on the second. The same goes for adding listeners mid-emit: they join from the next emit.
The event that fired before you listened
Synchronous delivery has a corollary that causes real production bugs. An event emitted when nobody is listening is simply lost. There’s no buffer, no replay.
The classic shape is a class that emits from its constructor:
class Connection extends EventEmitter {
constructor({ deferReady }) {
super();
if (deferReady) process.nextTick(() => this.emit('ready'));
else this.emit('ready');
}
}With deferReady: false, the constructor emits ready before it returns, which means before the caller has had any chance to call .on('ready'). Then:
import { once } from 'node:events';
const early = new Connection({ deferReady: false });
const ac = new AbortController();
setTimeout(() => ac.abort(), 200);
try {
await once(early, 'ready', { signal: ac.signal });
console.log('early: ready');
} catch (e) {
console.log('early:', e.name);
}
const deferred = new Connection({ deferReady: true });
await once(deferred, 'ready');
console.log('deferred: ready');early: AbortError
deferred: readyThe early connection waited for an event that had already happened and only gave up because of the abort. Without a timeout it would have waited forever, and you’d see a request that never completes and no error anywhere.
The fix belongs in the emitter: defer “lifecycle” events with process.nextTick (or queueMicrotask) so that the code which created the object finishes its synchronous setup, including attaching listeners, before anything fires. Node’s own APIs follow this rule. net.createServer().listen() emits listening asynchronously even when it could bind immediately, so server.on('listening') on the next line always catches it. On the consumer side, the habit is to attach listeners before calling the method that might trigger the event, and to check state where the emitter exposes it (a stream’s readableEnded, a socket’s readyState) rather than assuming an event is still to come.
A side note about the timeout. My first version used AbortSignal.timeout(200) instead of a manual setTimeout, and Node exited with code 13 and Warning: Detected unsettled top-level await. The timer behind AbortSignal.timeout doesn’t keep the process alive, so with nothing else scheduled, the process ended before the signal fired. In a server that’s irrelevant (the listening socket keeps the process alive), but in a script or a test it’s a confusing failure.
Errors: throwing listeners and the ‘error’ event
If a listener throws, the loop stops. Remaining listeners don’t run, and the exception propagates out of emit() to its caller:
const bus = new EventEmitter();
bus.on('msg', () => { throw new Error('listener bug'); });
bus.on('msg', () => console.log('never reached'));
try { bus.emit('msg'); } catch (e) { console.log('emit threw:', e.message); }emit threw: listener bugThat’s surprising from the emitter’s point of view: a bug in some unrelated subscriber now looks like a failure in the code that emitted. When listeners belong to different teams or plugins, wrap each one in its own try/catch at registration time, so one bad subscriber can’t stop the others.
The 'error' event name is special. Emitting 'error' with no listener attached throws the error (my test printed no error listener: boom from a catch around the emit). In a real server there’s usually no such catch, so an unhandled 'error' on a socket or stream crashes the process. Every emitter you create or receive that can fail needs an 'error' listener.
events.once(emitter, name) gives you a promise for the next occurrence and also listens for 'error' while it waits, rejecting if one arrives first:
once rejected: disk fullThat makes it the right tool for “await this one thing”. For a stream of events, events.on(emitter, name) returns an async iterator.
Async listeners are their own trap. The emitter ignores the promise an async listener returns, so a rejection inside it becomes an unhandledRejection (which, by default since Node 15, crashes the process). Passing { captureRejections: true } to the constructor routes those rejections to the emitter’s 'error' event instead. Running both variants:
emit returned true
error listener got: webhook failed
unhandledRejection: webhook failedThe plain emitter returned true from emit as if all was well, and its listener’s failure surfaced only as an unhandled rejection. The capturing emitter delivered the same failure to its 'error' listener.
Leaks and MaxListenersExceededWarning
Adding an eleventh listener for one event name prints a warning:
(node:25658) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limitThe limit isn’t enforced; it’s a hint. The usual cause is attaching a listener per request to a long-lived object (a database client, process) and never removing it. Each closure keeps its request’s data alive, and memory climbs. The fix is to remove listeners when the work ends (off, once, or an AbortSignal passed to events.once() or events.on()), not to raise the limit. Raise it only when many listeners are genuinely expected, like a broadcast hub with 50 known subscribers.
Practice question: a flaky test
“Our test does const db = new DbClient(url); db.on('connected', () => done());. It passes locally and times out in CI about one run in five. DbClient checks a local connection cache in its constructor and emits connected if it finds one. What’s wrong, and where do you fix it?”
Answer. When the cache has a connection, DbClient emits connected synchronously inside the constructor, before new DbClient(url) has returned and therefore before the test attaches its listener. The event is lost and done is never called. When the cache is empty, the client connects over the network and emits later, after the listener exists, so the test passes. Whether the cache is warm depends on what ran earlier in the same process, which explains why it’s intermittent and why CI (which runs more test files in one worker) sees it more often.
The real fix is in DbClient: emit lifecycle events asynchronously, process.nextTick(() => this.emit('connected')), so the event always fires after the constructor’s caller has had a chance to subscribe. That matches what Node’s own servers and sockets do. I’d also give the client a connected boolean or a ready() method returning a promise, so callers who arrive late can still find out. The test then becomes await once(db, 'connected'), which also fails fast if the client emits 'error' instead of hanging until the test runner’s timeout.