Skip to content
Node.js nd events 4 min read

events.once() & events.on() Helpers

The EventEmitter callback model is great for fan-out, but it fights against async/await. When you only care about the next time something happens, or you want to loop over a stream of events, callbacks force you into nested handlers and manual cleanup. The node:events module ships two static helpers — events.once() and events.on() — that bridge events into the promise world, letting you await a single event or drive a for await...of loop over many. Both accept an AbortSignal so you can cancel cleanly.

Awaiting a single event with events.once()

events.once(emitter, eventName) returns a Promise that resolves the first time the emitter fires eventName. The resolved value is an array of the arguments passed to emit(), so destructuring is the natural way to read it. This is perfect for “wait until ready” style code where one-time callbacks would otherwise interrupt a linear flow.

import { EventEmitter, once } from 'node:events';

const emitter = new EventEmitter();

setTimeout(() => emitter.emit('ready', 'db', 42), 100);

const [source, code] = await once(emitter, 'ready');
console.log(`Got "${source}" with code ${code}`);

Output:

Got "db" with code 42

A key feature is built-in error handling. If the emitter emits an 'error' event before the awaited event arrives, the returned promise rejects with that error — so a single try/catch covers both the happy path and failure.

import { EventEmitter, once } from 'node:events';

const emitter = new EventEmitter();
setTimeout(() => emitter.emit('error', new Error('connection refused')), 50);

try {
  await once(emitter, 'ready');
} catch (err) {
  console.error('Failed:', err.message);
}

Output:

Failed: connection refused

The special-cased rejection only applies to the 'error' event. If you are awaiting 'error' itself, the promise resolves with the error arguments instead of rejecting.

It works with any emitter, including built-ins. Waiting for a server to start listening is a common case:

import { createServer } from 'node:http';
import { once } from 'node:events';

const server = createServer((req, res) => res.end('ok'));
server.listen(0);
await once(server, 'listening');
console.log('Listening on port', server.address().port);
server.close();

Consuming a stream of events with events.on()

events.on(emitter, eventName) returns an async iterator. Each iteration yields an array of the arguments from one emit() call, letting you process an unbounded stream of events with for await...of. Unlike once(), the loop keeps running until you break, the signal aborts, or the emitter emits 'error' (which throws into the loop).

import { EventEmitter, on } from 'node:events';

const emitter = new EventEmitter();

let n = 0;
const timer = setInterval(() => emitter.emit('tick', ++n), 50);

for await (const [count] of on(emitter, 'tick')) {
  console.log('tick', count);
  if (count >= 3) break;
}
clearInterval(timer);

Output:

tick 1
tick 2
tick 3

Events emitted while your loop body is busy are buffered in order, so you never miss one — events.on() queues them internally and delivers them on the next iteration. This makes it a reliable backpressure-free way to serialize concurrent events.

Cancelling with an AbortSignal

Both helpers accept an options object with a signal. Aborting the signal rejects the once() promise — or ends the on() iterator — with an AbortError, after removing every listener the helper registered. This is the idiomatic way to add a timeout or wire event consumption into a request’s lifecycle.

import { EventEmitter, on } from 'node:events';

const emitter = new EventEmitter();
const ac = new AbortController();
setTimeout(() => ac.abort(), 120);

let n = 0;
const timer = setInterval(() => emitter.emit('data', ++n), 50);

try {
  for await (const [value] of on(emitter, 'data', { signal: ac.signal })) {
    console.log('received', value);
  }
} catch (err) {
  if (err.name === 'AbortError') console.log('stream cancelled');
}
clearInterval(timer);

Output:

received 1
received 2
stream cancelled

For a one-shot timeout, pass AbortSignal.timeout(ms) straight to once() — no manual controller needed:

await once(emitter, 'ready', { signal: AbortSignal.timeout(1000) });

once() vs on() at a glance

Aspectevents.once()events.on()
ReturnsPromise<any[]>AsyncIterableIterator<any[]>
Events handledThe first one onlyA continuous stream
Consumed withawaitfor await...of
'error' eventRejects the promiseThrows inside the loop
Abort behaviourRejects with AbortErrorIterator ends with AbortError
Buffers missed eventsN/A (single event)Yes, FIFO queue

Best Practices

  • Reach for once() whenever you need exactly one occurrence — it reads linearly and folds 'error' handling into your existing try/catch.
  • Use on() with for await...of to serialize a stream of events instead of nesting .on() callbacks and tracking state by hand.
  • Always pass a signal for long-lived on() loops; it guarantees listeners are removed and prevents leaks when the consumer goes away.
  • Remember both helpers yield arrays of emit arguments — destructure (const [a, b] = ...) for readable code.
  • Prefer AbortSignal.timeout(ms) over hand-rolled timers when you just want a deadline on a single awaited event.
  • Don’t use on() for emitters that fire once and never again — the loop will simply hang waiting; use once() there.
Last updated June 14, 2026
Was this helpful?