Ch. 6 · Node.js

Node.js Events and Listener Ordering

Trace synchronous EventEmitter delivery, listener order and once(), and fix the event that fires before anyone is listening for it.

~6 min readintermediateupdated Oct 19, 2026

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' });
JavaScript
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-2
Text

Every 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. on and its alias addListener append.
  • prependListener (and prependOnceListener) put a listener at the front. Use it sparingly; code that depends on running first is fragile, since someone else can prepend too.
  • once listeners are removed just before they’re called, so the bonus ran for A-1 and not for A-2.
  • emit returns true if there were any listeners and false otherwise. 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');
JavaScript
a runs, removing b
b runs
a runs, removing b
Text

b 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');
  }
}
JavaScript

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');
JavaScript
early: AbortError
deferred: ready
Text

The 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); }
JavaScript
emit threw: listener bug
Text

That’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 full
Text

That 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 failed
Text

The 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 limit
Text

The 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.

Further reading

More in Node.js

read ✓Node.js · hard

Node.js Test Isolation and Async Completion

Why an unawaited assert.rejects passes, how one unclosed server hung a test file, and node:test patterns that keep each test's state its own.

~7 min readread →
read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
read ✓Node.js · hard

Node.js Clustering Across CPU Cores

Fork one Node.js worker per core with cluster, replace workers that crash, and plan for the state that each process keeps to itself.

~7 min readread →
esc