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
| Aspect | events.once() | events.on() |
|---|---|---|
| Returns | Promise<any[]> | AsyncIterableIterator<any[]> |
| Events handled | The first one only | A continuous stream |
| Consumed with | await | for await...of |
'error' event | Rejects the promise | Throws inside the loop |
| Abort behaviour | Rejects with AbortError | Iterator ends with AbortError |
| Buffers missed events | N/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 existingtry/catch. - Use
on()withfor await...ofto serialize a stream of events instead of nesting.on()callbacks and tracking state by hand. - Always pass a
signalfor long-livedon()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; useonce()there.