skip to content

In Node, a helper promisifies a one-shot operation on a long-lived EventEmitter by adding 'done' and 'error' listeners inside a new Promise. After the helper is called thousands of times on the same emitter, what breaks, and what is the fix?

level: seniorimportance: nice to knowfreq 33%

answer

  1. settling does not unsubscribe anything
  2. the listener that never fired stays attached
  3. warning at eleven listeners on one event
  4. remove both handlers in each path
  5. node:events already ships this wrapper

basics

~20 s

The promise settles once, but whichever listener did not fire stays attached forever, so each call leaks a closure and everything it captures. Node warns about a possible leak past ten listeners, emits grow slower, and heap climbs. Remove both listeners on settle, or use events.once.

solid answer

~50 s

Settling the promise does not unsubscribe anything. If `done` fires, the `error` listener remains registered on the emitter — and vice versa — so every call permanently adds a closure that captures the request-scoped data of that call. On a long-lived emitter you first see a `MaxListenersExceededWarning` once a single event passes ten listeners, then steadily rising heap, then slower emits because every emit iterates the whole listener array. `emitter.setMaxListeners` only silences the warning; it is not a fix. The correct hand-written form names both handlers and has each remove the other with `emitter.off(...)` before settling. Better still, use `once(emitter, 'done')` from `node:events`: it attaches both listeners, removes them on settlement, rejects if `error` fires first, and fulfils with an array of the emitted arguments. And an `error` event with no listener throws, so a wrapper that subscribes only to success turns a recoverable failure into a crash.

code

javascript · 30 lines
javascript
import { EventEmitter, once } from 'node:events';

const hub = new EventEmitter();

// leaky: the listener that did not fire is never removed
function nextDoneLeaky(emitter) {
  return new Promise((resolve, reject) => {
    emitter.once('done', resolve);
    emitter.once('error', reject);
  });
}

// clean: each path removes both listeners before settling
function nextDone(emitter) {
  return new Promise((resolve, reject) => {
    const onDone = (v) => { cleanup(); resolve(v); };
    const onError = (e) => { cleanup(); reject(e); };
    function cleanup() {
      emitter.off('done', onDone);
      emitter.off('error', onError);
    }
    emitter.on('done', onDone);
    emitter.on('error', onError);
  });
}

for (let i = 0; i < 3; i++) nextDoneLeaky(hub).catch(() => {});
for (let i = 0; i < 3; i++) nextDone(hub).catch(() => {});
hub.emit('done', 'ok');
console.log('leaked error listeners:', hub.listenerCount('error'));

go deeper

for a junior

Know that adding a listener inside a promise wrapper does not remove it when the promise settles, and that node:events exports a once function that handles subscription and cleanup for you.

for a middle

Explain that emitter.once removes a listener only when its own event fires, so the unfired partner persists, and that removal by off requires holding a reference to the named handler.

for a senior

Connect the mechanism to production symptoms — the max-listener warning, growing heap from retained closures, slower emits as the listener array grows — and reject setMaxListeners as a fix.

for a principal

Set the boundary as policy: one-shot handshakes get a single shared promisified helper with guaranteed cleanup, while repeating events stay on the emitter's streaming interface rather than a promise per event.

## The leaky shape ```js function nextDone(emitter) { return new Promise((resolve, reject) => { emitter.once('done', resolve); emitter.once('error', reject); }); } ``` This looks careful — both channels are covered, and `once` means each listener fires at most once. The flaw is that `once` removes a listener only **when its own event fires**. On a successful call, `done` fires and its listener is removed; the `error` listener is never invoked and therefore stays registered on the emitter for the lifetime of that emitter. Call the helper ten thousand times against the same connection or process object and you have ten thousand dormant `error` listeners, each holding a `reject` function, each `reject` holding the promise, and the promise potentially holding whatever the awaiting frame captured. ## What you actually observe The first symptom is a warning on stderr: ``` MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 error listeners added to [Socket]. Use emitter.setMaxListeners() to increase limit ``` Node emits it when a single event name passes the emitter's max-listener threshold (`events.defaultMaxListeners`, 10 by default). It is a heuristic, not an error — and the message unhelpfully suggests raising the limit, which is why the classic wrong fix is `emitter.setMaxListeners(0)`. That silences the diagnostic and leaves the leak running. After the warning come the real effects. Heap grows in proportion to calls, because each retained closure keeps its captured objects alive. Emit latency grows too: emitting an event walks the entire listener array for that event and calls every entry, so a hot emitter with thousands of stale subscribers spends real CPU dispatching to listeners whose promises settled long ago. And if the emitter ever does emit `error`, every one of those stale `reject` functions runs — harmlessly, since each promise is already settled and further calls are ignored, but the CPU cost is real. ## The hand-written fix: pair the cleanup Each handler must remove the other before settling: ```js function nextDone(emitter) { return new Promise((resolve, reject) => { const onDone = (value) => { cleanup(); resolve(value); }; const onError = (err) => { cleanup(); reject(err); }; function cleanup() { emitter.off('done', onDone); emitter.off('error', onError); } emitter.on('done', onDone); emitter.on('error', onError); }); } ``` This requires named handlers, because `off` (an alias of `removeListener`) matches by function identity — you cannot remove an inline arrow you never kept a reference to. The `cleanup` function is also the natural place to release anything else the wrapper acquired. ## The built-in: events.once Node ships this exact wrapper: ```js import { once } from 'node:events'; const [value] = await once(emitter, 'done'); ``` `once(emitter, name)` returns a promise that fulfils with an **array** of the arguments the event was emitted with — emitters can emit several, and a promise fulfils with one value, so the array preserves them. It attaches an `error` listener as well and rejects if `error` fires while it is waiting (skipped when the event you are awaiting *is* `error`), and it removes both listeners on settlement. It also works with `EventTarget` objects and accepts an options object carrying an abort signal. Using it removes the whole class of bug, and it is what an interviewer wants to hear after you have diagnosed the leak: the discipline is real, but you should not be reimplementing it per call site. ## The other half: an error event with no listener An `EventEmitter` treats `error` specially. Emitting `error` with no registered listener throws the error (crashing the process by default) rather than silently dropping it. So a wrapper that subscribes only to the success event does more than fail to reject — it converts a failure the emitter intended you to handle into an unhandled throw. Whenever you promisify an emitter, wire the failure channel in the same statement as the success channel. ## When a promise is the wrong shape All of this assumes a **one-shot** operation: the emitter will produce this result once and you want a single settlement. If the emitter produces a stream of events, a promise cannot represent it — it settles once and drops everything after. Recognising that boundary is part of the answer: promisify the one-shot handshake, and use the emitter's streaming interface for repeated events rather than creating a promise per event in a loop.

  • Why does events.once fulfil with an array instead of the value itself?
    Because an emitter can emit any number of arguments — `emit('done', body, statusCode)` — while a promise fulfils with exactly one value. Collecting them into an array preserves every argument without inventing a convention. Call sites destructure it: `const [body, code] = await once(emitter, 'done')`, which reads no worse and loses nothing.
  • Is emitter.setMaxListeners(0) ever the right response to the warning?
    Only when you have proven the listener count is intentional and bounded — a hub with dozens of legitimate long-lived subscribers, for instance. As a response to a count that grows with traffic it is the wrong fix: it deletes the only diagnostic pointing at the leak while the retained closures keep accumulating and emit latency keeps rising.
  • What happens if your wrapper subscribes only to the success event and the emitter emits error?
    EventEmitter treats `error` specially: emitting it with no listener throws the error rather than dropping it, which by default terminates the process. So the omission does more than leave the promise pending — it converts a failure the emitter expected you to handle into a crash. Subscribe to the failure channel in the same statement as the success one.

saying these in an interview costs you the question

  • Assumes settling a promise removes its event listeners
  • Raises the max-listener limit to silence the warning
  • Uses inline arrows then cannot remove them by identity
  • Subscribes only to the success event
  • Creates one promise per event for a repeating stream

context