Events & EventEmitter

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; all versions and stability levels were checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says otherwise. Events are how Node.js lets one part of a program announce that something happened without knowing who is interested: a server announces a request, a stream announces data, a child process announces that it exited. The pattern is implemented by one small class, EventEmitter in node:events. Newer APIs often return promises instead of taking callbacks (see Callbacks, Promises & async/await), but emitters remain the backbone of streams, HTTP, sockets and child processes (see Streams and Child Processes).

The node:events module as a whole is Stability 2 (Stable). Inside it the stability is not uniform: the EventEmitter class, events.once(), events.on(), events.errorMonitor, events.captureRejections and events.addAbortListener() are Stable (addAbortListener() was Experimental until v24.0.0 and v22.16.0, so on older 22 releases it carries the Experimental label); EventTarget and Event are Stable (no longer experimental in v15.4.0) and CustomEvent is Stable too (stable in v22.1.0 and v20.13.0, no longer experimental in v23.0.0); and a handful of members of the Event class are Stability 3 (Legacy): event.cancelBubble, event.returnValue, event.srcElement, event.initEvent() and similar. Two things are not part of this page: how the event loop schedules the callbacks that emit events (see The Event Loop) and how errors escape to the process (see Errors & Error Handling). The running scenario is the Bookshelf application that grows across this section.

The stability and version map

Table 1. Features covered on this page and when they appeared
Feature Stability (Node.js 26 docs) Version notes

EventEmitter class, on, once, emit, removeListener, setMaxListeners

2 Stable

Present since v0.1.x. emitter.off() since v10.0.0; prependListener() and prependOnceListener() since v6.0.0.

emitter.listenerCount(eventName, listener)

Stable

The optional listener argument was added in v19.8.0 and v18.16.0.

captureRejections option and Symbol.for('nodejs.rejection')

Stable

Added in v13.4.0 and v12.16.0; no longer experimental in v17.4.0 and v16.14.0.

events.errorMonitor

Stable

Added in v13.6.0 and v12.17.0.

events.once()

Stable

Added in v11.13.0 and v10.16.0; signal option since v15.0.0.

events.on()

Stable

Added in v13.6.0 and v12.16.0; close, highWatermark and lowWatermark options in v20.0.0; highWaterMark and lowWaterMark spelling in v22.0.0 and v20.13.0.

events.setMaxListeners(), events.getEventListeners(), events.getMaxListeners()

Stable

Added in v15.4.0, v15.2.0 and v14.17.0, and v19.9.0 and v18.17.0; they work on EventTarget objects too.

events.listenerCount(emitterOrTarget, eventName)

Stable

The long-standing documentation-only deprecation (v3.2.0) was revoked and EventTarget support added in v25.4.0 and v24.14.0.

events.addAbortListener()

Stable

Added in v20.5.0 and v18.18.0; Stable since v24.0.0 and v22.16.0.

EventTarget, Event, globals

Stable

Added in v14.5.0; globals since v15.0.0; no longer experimental in v15.4.0.

CustomEvent

Stable

Added in v18.7.0 and v16.17.0; global without a flag since v19.0.0; stable since v22.1.0 and v20.13.0; no longer experimental in v23.0.0.

events.EventEmitterAsyncResource

Stable

Added in v17.4.0 and v16.14.0.

NodeEventTarget

Stable

Added in v14.5.0; an internal shape implemented by some Node.js objects, not something to construct.

The EventEmitter API

An emitter is an object that keeps a map from event names to ordered lists of listener functions. emit(name, …​args) calls each listener for that name with the arguments; on(name, listener) appends a listener. Event names are strings or symbols and carry no meaning for Node.js itself (a few names are special, covered below). The class is exported as the default export and as the named export EventEmitter of node:events; using node:events with the node: prefix is the current style. Subclassing is the normal way to add domain behaviour, because the subclass inherits the whole API.

Subclassing EventEmitter and the core registration methods
import { EventEmitter } from 'node:events';

class Library extends EventEmitter {
  #books = new Map();

  add(book) {
    this.#books.set(book.id, book);
    this.emit('book:added', book);
    return this;
  }
}

const library = new Library();

library.on('book:added', (book) => console.log('indexer saw', book.title));
library.once('book:added', (book) => console.log('first book ever:', book.title));
library.prependListener('book:added', () => console.log('audit log runs first'));

library.add({ id: 1, title: 'Dune' }).add({ id: 2, title: 'Emma' });

console.log(library.eventNames());                // [ 'book:added' ]
console.log(library.listenerCount('book:added')); // 2 (the once() listener is gone)
console.log(library.emit('nobody:listens'));      // false
console.log(library.emit('book:added', { id: 3, title: 'Ulysses' })); // true
audit log runs first
indexer saw Dune
first book ever: Dune
audit log runs first
indexer saw Emma
[ 'book:added' ]
2
false
audit log runs first
indexer saw Ulysses
true

Three details matter here. once() removes its listener before calling it, so the second book:added does not reach it. prependListener() (and prependOnceListener()) put a listener at the front of the list, which is how a cross-cutting concern such as an audit log runs ahead of the business listeners. And emit() returns true when the event had listeners and false when it had none, which is the only feedback an emitter gives its caller.

Table 2. The EventEmitter methods at a glance
Method What it does

on(name, fn) / addListener(name, fn)

Appends fn. Adding the same function twice registers it twice. Returns the emitter, so calls chain.

once(name, fn) / prependOnceListener(name, fn)

A listener that is removed before it runs, so it runs at most once.

prependListener(name, fn)

Adds fn at the beginning of the listener list.

off(name, fn) / removeListener(name, fn)

Removes at most one registration of fn, the most recently added one when it is registered several times.

removeAllListeners([name])

Removes every listener of one event, or of all events. Use it sparingly on objects you did not create: it removes listeners that other code installed.

emit(name, …​args)

Calls every listener synchronously; returns whether any listener existed.

listenerCount(name[, fn]), listeners(name), rawListeners(name), eventNames()

Introspection. listeners() returns the original functions; rawListeners() returns the once() wrappers (reach the original with their listener property).

setMaxListeners(n) / getMaxListeners()

The per-event warning threshold; see the next section.

Listener bookkeeping has a subtle rule that is worth demonstrating. emit() works on a snapshot of the listener list taken when it starts, so a listener that removes a sibling does not stop that sibling from running during the current emission, only during later ones. The two lifecycle events 'newListener' (emitted before a listener is added) and 'removeListener' (emitted after one is removed) let a wrapper react to subscriptions, for example to start a resource lazily when the first listener arrives.

Snapshot semantics, once() wrappers and lifecycle events
import { EventEmitter } from 'node:events';

const e = new EventEmitter();
const a = () => { console.log('a'); e.off('x', b); };
const b = () => console.log('b');
const c = () => console.log('c');
e.on('x', a); e.on('x', b); e.on('x', c);

e.emit('x');           // listeners are snapshotted: b still runs this time
console.log('--');
e.emit('x');           // b is gone now

e.once('y', c);
console.log(e.listeners('y')[0] === c);        // true: the original function
console.log(e.rawListeners('y')[0] === c);     // false: the once() wrapper
console.log(e.rawListeners('y')[0].listener === c);

e.on('newListener', (name, fn) => console.log('newListener', String(name)));
e.on('removeListener', (name, fn) => console.log('removeListener', String(name)));
e.on('z', c);
e.off('z', c);
e.removeAllListeners('x');
console.log(e.eventNames());
a
b
c
--
a
c
true
false
true
newListener removeListener
newListener z
removeListener z
removeListener x
removeListener x
[ 'y', 'newListener', 'removeListener' ]

The line newListener removeListener appears because registering the 'removeListener' listener itself triggers 'newListener'. The two removeListener x lines come from removeAllListeners('x'), which removed the two listeners (a and c) that were still registered.

Synchronous emission, ordering and this

emit() is an ordinary function call that runs every listener to completion, in registration order, before it returns. There is no queue and no scheduling: if a listener takes a second, emit() takes a second. That gives emitters their strongest guarantee, that listeners observe events in the order they happened and that state a listener changes is visible to the next one, and their sharpest edge, that a slow or throwing listener affects the code that called emit(). A listener that wants to continue later switches explicitly to asynchronous work with setImmediate(), queueMicrotask() or an await; the ordering of those is explained in The Event Loop.

The value of this inside a listener declared with the function keyword is the emitter; an arrow function has no this of its own and keeps the surrounding one (at module scope of an ES module that is undefined).

Listeners run synchronously, before emit() returns
import { EventEmitter } from 'node:events';

const emitter = new EventEmitter();

emitter.on('tick', function () {
  console.log('regular function: this === emitter', this === emitter);
});
emitter.on('tick', () => {
  console.log('arrow function: this is', typeof this);
});
emitter.on('tick', () => {
  setImmediate(() => console.log('3. deferred listener work'));
});

console.log('1. before emit');
emitter.emit('tick');
console.log('2. after emit (all listeners already ran)');
1. before emit
regular function: this === emitter true
arrow function: this is undefined
2. after emit (all listeners already ran)
3. deferred listener work

Synchronous emission has a classic consequence, sometimes called Zalgo when an API is sometimes synchronous and sometimes not: an event emitted from a constructor fires before the caller has had any chance to attach a listener, so it is lost. Deferring the emission with queueMicrotask() makes it reliable: the callback runs once the constructor and the caller’s current statement have finished, so the listener is already attached. Older code does the same with process.nextTick(), which is Legacy since v22.7.0 and v20.18.0; the documentation recommends queueMicrotask() for new code. The same example shows the second trap of the promise helpers: awaiting events.once() calls one after the other can miss the second event when both are emitted in the same tick, because the second once() is only registered after the first await resumes.

Two ways to lose an event, and the fixes
import { EventEmitter, once } from 'node:events';

// Pitfall 1: emitting from a constructor happens before anyone can listen
class Importer extends EventEmitter {
  constructor() {
    super();
    this.emit('ready');                          // nobody is subscribed yet
  }
}
new Importer().on('ready', () => console.log('never printed'));

// Fix: defer the emission until the caller has subscribed
class SafeImporter extends EventEmitter {
  constructor() {
    super();
    queueMicrotask(() => this.emit('ready'));
  }
}
new SafeImporter().on('ready', () => console.log('SafeImporter: ready received'));

// Pitfall 2: two awaited once() calls in sequence can miss the second event
const ee = new EventEmitter();
async function listen() {
  await once(ee, 'first');
  console.log('got first');
  await once(ee, 'second');                      // registered one microtask too late
  console.log('got second');
}
listen();
ee.emit('first');
ee.emit('second');                               // emitted before the second once() exists
setTimeout(() => console.log('second was missed:', ee.listenerCount('second') === 1), 10);

// Fix: register both promises before emitting
const ee2 = new EventEmitter();
const both = Promise.all([once(ee2, 'first'), once(ee2, 'second')]);
ee2.emit('first');
ee2.emit('second');
console.log('Promise.all saw', (await both).length, 'events');
SafeImporter: ready received
got first
Promise.all saw 2 events
second was missed: true

Max listeners and leak warnings

By default an emitter warns when more than 10 listeners are registered for one event name. The limit exists to catch the most common emitter bug: a listener added on every request, connection or retry and never removed. It is not a hard limit; the extra listeners are added, and Node.js prints a MaxListenersExceededWarning once per event name to stderr and emits a 'warning' event on process. The thresholds are controlled at three levels: events.defaultMaxListeners for every emitter (changing it also affects emitters created earlier, so avoid it in libraries), emitter.setMaxListeners(n) for one emitter, which wins over the default, and the static events.setMaxListeners(n, …​targets) to set the limit on several emitters or EventTarget objects at once. 0 or Infinity disables the warning. AbortSignal objects are the exception that does not warn by default, and events.getMaxListeners() reads the effective value from any emitter or target.

The leak warning, per-emitter limits and AbortSignal
import { EventEmitter, getMaxListeners, setMaxListeners, defaultMaxListeners } from 'node:events';

console.log(defaultMaxListeners);                 // 10
process.on('warning', (w) => {
  console.log(w.name, w.type, w.count, w.emitter === emitter);
  console.log(w.message);
});

const emitter = new EventEmitter();
for (let i = 0; i < 11; i++) emitter.on('data', () => {});

const quiet = new EventEmitter();
quiet.setMaxListeners(0);                         // 0 and Infinity mean unlimited
for (let i = 0; i < 50; i++) quiet.on('data', () => {});
console.log(quiet.getMaxListeners());

const ac = new AbortController();
console.log(getMaxListeners(ac.signal));          // 0: signals never warn by default
setMaxListeners(20, ac.signal, emitter);          // several targets at once
console.log(getMaxListeners(ac.signal), emitter.getMaxListeners());
10
0
0
20 20
(node:46904) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit
(Use `node --trace-warnings ...` to show where the warning was created)
MaxListenersExceededWarning data 11 true
Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MaxListeners is 10. Use emitter.setMaxListeners() to increase limit

The warning object carries the emitter, the event type and the listener count, so a 'warning' handler can log which emitter leaked; run with --trace-warnings to see the stack that added the eleventh listener. The first console.log(quiet.getMaxListeners()) printing 0 shows the unlimited setting. Raise the limit only when a high listener count is intentional (a broadcast emitter with many subscribers, for example); otherwise fix the leak by removing the listener when its owner is finished, using once(), off(), or the signal option of EventTarget (below).

The special 'error' event

The event name 'error' has behaviour built in. If an emitter emits 'error' and has no listener for it, emit() throws: the argument itself when it is an Error, or an Error with code ERR_UNHANDLED_ERROR (whose context property holds the original value) when it is not. Inside an event callback that exception is usually uncaught, so the process exits. This is deliberate: an error nobody handles must not be silently ignored. Install an 'error' listener on every emitter that can fail (streams, sockets, child processes and servers all do) and treat the absence of one as a bug.

events.errorMonitor is a symbol that can be used as an event name to observe 'error' events without consuming them: its listeners run before the regular 'error' listeners and do not count as handlers, so the emitter still throws when nobody else handles the error. It is the right hook for logging and metrics.

Unhandled 'error' events throw, errorMonitor observes them
import { EventEmitter, errorMonitor } from 'node:events';

const emitter = new EventEmitter();
emitter.on(errorMonitor, (err) => console.log('monitor sees:', err instanceof Error ? err.message : err));

try {
  emitter.emit('error', new Error('disk full'));
} catch (err) {
  console.log('thrown to the emit() caller:', err.message);
}

try {
  emitter.emit('error', 'just a string');
} catch (err) {
  console.log(err.name, err.code);
  console.log(err.context);
}

emitter.on('error', (err) => console.log('handled:', err.message));
emitter.emit('error', new Error('disk full again'));
monitor sees: disk full
thrown to the emit() caller: disk full
monitor sees: just a string
Error ERR_UNHANDLED_ERROR
just a string
monitor sees: disk full again
handled: disk full again

The documentation mentions the deprecated node:domain module as a way to guard against the crash; it is deprecated and not a design to build on. Two further rules follow from the same behaviour. First, an 'error' listener must not be an async function: a rejection inside it has no further 'error' handler to go to (see the next section). Second, EventTarget has no special 'error' event at all, which matters when code moves between the two APIs.

Async listeners and captureRejections

emit() ignores what listeners return. An async listener therefore returns a promise that nobody awaits, and if it throws, the result is an unhandled promise rejection, which terminates the process on every supported line unless a process.on('unhandledRejection') handler exists (see Errors & Error Handling). The captureRejections constructor option (or EventEmitter.captureRejections = true to change the default for new emitters) installs a rejection handler on the promise a listener returns. The error is routed to the method named Symbol.for('nodejs.rejection') (exported as events.captureRejectionSymbol) when the emitter defines one, and otherwise to the emitter’s 'error' event.

Capturing rejections from async listeners
import { EventEmitter, captureRejectionSymbol } from 'node:events';

const unsafe = new EventEmitter();
unsafe.on('job', async () => { throw new Error('async listener failed'); });

const safe = new EventEmitter({ captureRejections: true });
safe.on('job', async () => { throw new Error('captured failure'); });
safe.on('error', (err) => console.log('routed to error:', err.message));
safe.emit('job');

class Worker extends EventEmitter {
  constructor() {
    super({ captureRejections: true });
  }
  [captureRejectionSymbol](err, eventName, ...args) {
    console.log(`custom handler: ${err.message} while handling "${String(eventName)}"`, args);
  }
}
const worker = new Worker();
worker.on('job', async (id) => { throw new Error('boom'); });
worker.emit('job', 42);
console.log(captureRejectionSymbol === Symbol.for('nodejs.rejection'));

process.on('unhandledRejection', (reason) => console.log('unhandledRejection:', reason.message));
unsafe.emit('job');
true
routed to error: captured failure
custom handler: boom while handling "job" [ 42 ]
unhandledRejection: async listener failed

Notice the order of the output: the unhandledRejection line comes last although unsafe.emit('job') ran last, because the rejection is reported after the current synchronous code finished. The routed errors do not have their own catch handler, to avoid infinite loops, so the official recommendation is to not use async functions as 'error' listeners. Note also that captureRejections is only about listeners that return rejected promises; it does not wait for them and emit() still returns immediately. To run listeners and wait for all of them, collect the returned promises yourself: await Promise.all(emitter.listeners('job').mapfn) ⇒ fn(job).

events.once() and events.on(): events as promises and async iterators

events.once(emitter, name) returns a promise that resolves with the array of arguments of the next occurrence of the event. It also listens for 'error' and rejects with it if that arrives first (unless the awaited event is itself 'error'), and it removes both listeners when it settles, so it never leaks. An AbortSignal passed in options.signal cancels the wait with an AbortError. It is the building block for "wait until the server is listening" and "wait until the stream finished", and it works with EventTarget objects too.

events.on(emitter, name) is the repeating counterpart: it returns an async iterator that yields the argument array of every occurrence. It throws on 'error', removes its listeners when the loop exits, and ends cleanly when an event listed in options.close is emitted, or with an AbortError when options.signal is aborted. The body of a for await loop processes one event at a time; events that arrive in the meantime are buffered, so a slow consumer sees every event in order but memory can grow. highWaterMark and lowWaterMark (the older highWatermark spelling still works) make on() pause an emitter that implements pause() and resume(), such as a stream, while the buffer is full.

events.once() and events.on() with close and signal
import { EventEmitter, once, on } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';

const importer = new EventEmitter();

// once(): a promise for the next occurrence; the resolved value is the argument ARRAY
setTimeout(() => importer.emit('ready', 'csv', 120), 10);
const [format, rows] = await once(importer, 'ready');
console.log('ready:', format, rows);

// once() rejects if 'error' is emitted first, and removes its listeners
setTimeout(() => importer.emit('error', new Error('parse failed')), 10);
try {
  await once(importer, 'ready');
} catch (err) {
  console.log('rejected:', err.message);
}
console.log('listeners left:', importer.listenerCount('ready'), importer.listenerCount('error'));

// once() with a signal
const ac = new AbortController();
setTimeout(() => ac.abort(), 10);
try {
  await once(importer, 'ready', { signal: ac.signal });
} catch (err) {
  console.log(err.name, err.code);
}

// on(): an async iterator over every occurrence, ended by a close event
queueMicrotask(async () => {
  for (const n of [1, 2, 3]) {
    importer.emit('row', `row-${n}`);
    await sleep(5);
  }
  importer.emit('end');
});
for await (const [row] of on(importer, 'row', { close: ['end'] })) {
  console.log('got', row);
}
console.log('iteration finished, listeners:', importer.listenerCount('row'));

// on() with a signal ends the loop with an AbortError
const ac2 = new AbortController();
setTimeout(() => ac2.abort(), 20);
try {
  for await (const [row] of on(importer, 'never', { signal: ac2.signal })) {
    console.log(row);
  }
} catch (err) {
  console.log('loop ended with', err.name);
}
ready: csv 120
rejected: parse failed
listeners left: 0 0
AbortError ABORT_ERR
got row-1
got row-2
got row-3
iteration finished, listeners: 0
loop ended with AbortError

The next example shows the buffering guarantee and the watermark options on a stream, which pauses and resumes when on() is used with highWaterMark.

Buffering and watermarks with events.on()
import { EventEmitter, on } from 'node:events';
import { Readable } from 'node:stream';
import { setTimeout as sleep } from 'node:timers/promises';

// burst of events + slow consumer: on() buffers; highWaterMark pauses a Readable source
const ee = new EventEmitter();
setImmediate(() => {
  for (let i = 0; i < 5; i++) ee.emit('tick', i);
  ee.emit('done');
});
const seen = [];
for await (const [n] of on(ee, 'tick', { close: ['done'] })) {
  await sleep(1);
  seen.push(n);
}
console.log('slow consumer still saw all events in order:', seen.join(','));

// Readable emits 'data' and implements pause()/resume(), so highWaterMark applies
const source = Readable.from(['a', 'b', 'c', 'd']);
for await (const [chunk] of on(source, 'data', { close: ['end'], highWaterMark: 1, lowWaterMark: 1 })) {
  console.log('chunk', chunk);
}
slow consumer still saw all events in order: 0,1,2,3,4
chunk a
chunk b
chunk c
chunk d

For turning whole streams into async iterators, prefer the stream’s own async iteration (see Streams); events.on() is the general tool for emitters that are not streams.

EventTarget, Event and CustomEvent

EventTarget and Event are the web platform’s version of the pattern, available as globals since v15.0.0 and also exported from node:events; CustomEvent (global since v19.0.0) carries arbitrary data in its detail property. They are what AbortSignal is built on, so the browser knowledge of addEventListener transfers directly (see Globals & Web Platform APIs). The Node.js implementation differs from the DOM in two documented ways: there is no hierarchy, so events do not bubble or capture through nested targets, and a listener that returns a rejected promise is handled like one that throws.

Compared with EventEmitter, an EventTarget has different rules that matter when choosing between them:

Table 3. EventEmitter versus EventTarget
Aspect EventEmitter EventTarget

Registration

on, once, off, prependListener

addEventListener(type, listener, options) with once, signal, capture options; removeEventListener

Same listener registered twice

Runs twice

Ignored (registered once per type)

Listener argument

Any number of arbitrary arguments

One Event object (data in CustomEvent.detail)

Special 'error'

Throws when unhandled; errorMonitor

None; a throwing listener becomes an uncaught exception, and the other listeners still run

Async listeners

Rejection is unhandled unless captureRejections

A rejected promise is captured like a thrown error

Cancelling

off(), or events.once() and events.on() with a signal

The signal option removes the listener when the signal aborts

Introspection

listeners(), listenerCount()

Only events.getEventListeners() and events.listenerCount()

Return value of dispatch

emit() returns whether listeners existed

dispatchEvent() returns false if a cancelable event was cancelled

EventTarget, CustomEvent, the signal option and error handling
const target = new EventTarget();

function onPing(event) {
  console.log(event.type, event instanceof Event, event.target === target);
}
target.addEventListener('ping', onPing);
target.addEventListener('ping', onPing);        // same function twice: registered once
target.addEventListener('ping', { handleEvent(e) { console.log('object listener', this !== undefined); } }, { once: true });
target.dispatchEvent(new Event('ping'));
target.dispatchEvent(new Event('ping'));

const ac = new AbortController();
target.addEventListener('data', (e) => console.log('data', e.detail), { signal: ac.signal });
target.dispatchEvent(new CustomEvent('data', { detail: { isbn: '978-1098145187' } }));
ac.abort();                                      // removes the listener
console.log(target.dispatchEvent(new CustomEvent('data', { detail: 'ignored' })));

// cancelable events: dispatchEvent returns false if preventDefault() was called
const cancelable = new Event('save', { cancelable: true });
target.addEventListener('save', (e) => e.preventDefault());
console.log(target.dispatchEvent(cancelable), cancelable.defaultPrevented);

// a throwing listener does not stop the dispatch and becomes an uncaughtException
process.on('uncaughtException', (err, origin) => console.log('uncaught:', err.message, origin));
target.addEventListener('boom', () => { throw new Error('listener threw'); });
target.addEventListener('boom', () => console.log('second listener still ran'));
target.dispatchEvent(new Event('boom'));

// async listeners: a rejection is captured the same way
target.addEventListener('later', async () => { throw new Error('async listener rejected'); });
target.dispatchEvent(new Event('later'));
ping true true
object listener true
ping true true
data { isbn: '978-1098145187' }
true
false true
second listener still ran
uncaught: listener threw uncaughtException
uncaught: async listener rejected uncaughtException

The docs note that errors from EventTarget listeners are currently forwarded to process.on('error') before 'uncaughtException', a behaviour they describe as deprecated and going to change; do not rely on a process.on('error') handler. A NodeEventTarget is the hybrid shape that some Node.js objects (such as worker MessagePort objects) expose: it adds on, once, off, emit, listenerCount and similar methods to an EventTarget, but is not an EventEmitter: it does not implement prependListener(), rawListeners() or errorMonitor, never emits 'newListener', and registers a given listener once per type. Read its documentation before assuming the full EventEmitter API.

addAbortListener and AbortSignal

An AbortSignal is an EventTarget whose 'abort' event fires once. Listening with signal.addEventListener('abort', …​) is unsafe in library code: any holder of the signal can call event.stopImmediatePropagation() and prevent later listeners from running, which leaks the resources they would have released, and it is easy to forget to remove the listener. events.addAbortListener(signal, listener) registers a listener that stopImmediatePropagation() cannot suppress and returns a Disposable, so that it can be removed by calling its [Symbol.dispose]() method or automatically with the using declaration. If the signal has already aborted, the listener still runs, in a microtask. More about cancellation across fs, timers, fetch and child processes is in Async Context & Cancellation.

addAbortListener() and stopImmediatePropagation()
import { addAbortListener } from 'node:events';

const ac = new AbortController();
// Any code holding the signal can silence later 'abort' listeners
ac.signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
ac.signal.addEventListener('abort', () => console.log('addEventListener listener: skipped'));
addAbortListener(ac.signal, () => console.log('addAbortListener listener: still runs'));
ac.abort();
addAbortListener listener: still runs
addAbortListener() with a disposable and a late listener
import { addAbortListener, getEventListeners } from 'node:events';

const ac = new AbortController();
const disposable = addAbortListener(ac.signal, (event) => {
  console.log('aborted, reason:', ac.signal.reason.message, event.type);
});
console.log(getEventListeners(ac.signal, 'abort').length);   // 1
ac.abort(new Error('user cancelled'));
disposable[Symbol.dispose]();                                  // removes the listener

// A listener added after the signal is already aborted still runs, in a microtask
addAbortListener(ac.signal, () => console.log('late listener runs anyway'));
console.log('end of script');
1
aborted, reason: user cancelled abort
end of script
late listener runs anyway

The using declaration (explicit resource management) removes the listener when the block exits. It needs the V8 version of Node.js 24 and 26: on Node.js 22 the same file fails with a SyntaxError at parse time, and the [Symbol.dispose]() call shown above is the equivalent.

Scoped listener with the using declaration (Node.js 24 and 26)
import { addAbortListener, getEventListeners } from 'node:events';

const ac = new AbortController();
{
  using _listener = addAbortListener(ac.signal, () => console.log('never runs'));
  console.log('inside scope:', getEventListeners(ac.signal, 'abort').length);
}
console.log('after scope:', getEventListeners(ac.signal, 'abort').length);
inside scope: 1
after scope: 0

The emitters built into Node.js

Most callback-less, long-lived objects in the core are emitters. Knowing which events they emit, and that they follow the rules above ('error' must be handled, listeners run synchronously), is a large part of using the core APIs correctly.

Table 4. Built-in emitters and the events that matter most
Object Events Notes

process

'exit', 'beforeExit', 'uncaughtException', 'unhandledRejection', 'warning', signals such as 'SIGINT'

See Process, Signals & Exit Codes. 'exit' listeners must be synchronous.

http.Server, https.Server, net.Server

'listening', 'request', 'connection', 'close', 'error', 'upgrade'

See HTTP Server Fundamentals. A listen failure such as EADDRINUSE arrives as 'error'.

Streams (Readable, Writable, Duplex, Transform)

'data', 'end', 'finish', 'close', 'error', 'drain'

See Streams. 'error' must be handled on every stream in a chain, or use pipeline().

child_process.ChildProcess

'spawn', 'exit', 'close', 'error', 'message'

'exit' fires when the process ends; 'close' after its stdio streams are closed too. See Child Processes.

net.Socket, http.ClientRequest, http.IncomingMessage

'connect', 'data', 'end', 'timeout', 'close', 'error'

Sockets and HTTP messages are streams as well. See TCP, UDP & DNS.

readline.Interface

'line', 'close', 'history'

See Console, Readline & Terminal I/O.

fs.FSWatcher, fs.ReadStream, fs.WriteStream

'change', 'close', 'error', 'open', 'ready'

See Directories, Glob & Watch.

worker_threads.Worker, MessagePort

'online', 'message', 'error', 'exit'

MessagePort is a NodeEventTarget. See Worker Threads.

cluster

'fork', 'online', 'listening', 'disconnect', 'exit'

See Cluster & Scaling.

AbortSignal, fetch Response bodies

'abort' on the signal (an EventTarget)

Cancellation is covered above. fetch itself uses promises and web streams, not an emitter.

The next example touches three of them and uses events.once() to wait for them, so no hand-written callbacks are needed. It shows the 'exit' versus 'close' distinction on a child process and that process itself is an EventEmitter instance.

Built-in emitters: HTTP server, readable stream, child process and process
import { createServer } from 'node:http';
import { Readable } from 'node:stream';
import { spawn } from 'node:child_process';
import { once } from 'node:events';

// http.Server is an EventEmitter
const server = createServer((req, res) => res.end('ok'));
server.on('listening', () => console.log('server listening'));
server.on('close', () => console.log('server closed'));
server.listen(0);
await once(server, 'listening');
const { port } = server.address();

// the ClientRequest and IncomingMessage objects emit events as well
const res = await fetch(`http://127.0.0.1:${port}/`);
console.log('fetch status', res.status);
server.close();
server.closeAllConnections();
await once(server, 'close');

// streams are EventEmitters
const readable = Readable.from(['a', 'b']);
readable.on('data', (chunk) => console.log('data', chunk));
await once(readable, 'end');
console.log('readable ended');

// child processes: 'exit' fires when the process ends, 'close' when its stdio streams are closed too
const child = spawn(process.execPath, ['-e', 'console.log("hi from child")']);
child.stdout.on('data', (d) => process.stdout.write(`child said: ${d}`));
child.on('exit', (code) => console.log('exit', code));
child.on('close', (code) => console.log('close', code));
await once(child, 'close');

// the process object is an EventEmitter too
process.on('exit', (code) => console.log('process exit', code));
console.log(process instanceof (await import('node:events')).EventEmitter);
server listening
fetch status 200
server closed
data a
data b
readable ended
child said: hi from child
exit 0
close 0
true
process exit 0

Typing events in TypeScript

With @types/node, EventEmitter is generic: the type argument is an object type that maps each event name to the tuple of its argument types, and on(), once() and emit() are then checked against it. With Node.js’s type stripping (see TypeScript in Node.js) the file below runs directly; it also type-checked with tsc --noEmit --module nodenext --target es2022 --strict --types node using TypeScript 5.9 and @types/node 26. Without the type argument, names and arguments are not checked at all, so a typo in an event name is a silent bug.

A typed emitter (type stripping, Node.js 22.18 and later)
import { EventEmitter } from 'node:events';

type BookEvents = {
  added: [id: number, title: string];
  removed: [id: number];
  error: [error: Error];
};

class Catalog extends EventEmitter<BookEvents> {
  add(id: number, title: string): void {
    this.emit('added', id, title);
  }
}

const catalog = new Catalog();
catalog.on('added', (id, title) => console.log(id, title.toUpperCase()));
catalog.add(1, 'Dune');
1 DUNE

EventEmitterAsyncResource and context propagation

A plain emitter runs listeners in the asynchronous context of whoever calls emit(), which is usually what you want. When the emitter is a pool or a shared object and its listeners should instead see the context in which the emitter was created (for example a request identifier stored in AsyncLocalStorage), events.EventEmitterAsyncResource binds the emitter to an AsyncResource created at construction time. Call emitDestroy() when the emitter is done.

EventEmitterAsyncResource keeps the construction-time async context
import { EventEmitterAsyncResource } from 'node:events';
import { AsyncLocalStorage } from 'node:async_hooks';

const als = new AsyncLocalStorage();

// An ordinary emitter runs listeners in the context of whoever calls emit()
// EventEmitterAsyncResource runs them in the context captured at construction
const plain = new (await import('node:events')).EventEmitter();
let tracked;
als.run('req-1', () => {
  tracked = new EventEmitterAsyncResource({ name: 'BookshelfEmitter' });
});

plain.on('x', () => console.log('plain emitter listener sees:', als.getStore()));
tracked.on('x', () => console.log('tracked emitter listener sees:', als.getStore()));

als.run('req-2', () => {
  plain.emit('x');
  tracked.emit('x');
});
tracked.emitDestroy();
plain emitter listener sees: req-2
tracked emitter listener sees: req-1

Pitfalls and good practice

Table 5. Common mistakes with emitters and how to avoid them
Mistake What to do instead

No 'error' listener on an emitter that can fail

Always add one; use errorMonitor for logging and a real handler for recovery.

Registering a listener per request and never removing it

Use once(), off() in a cleanup path, or a signal; watch for MaxListenersExceededWarning instead of raising the limit.

Emitting from a constructor

Defer with queueMicrotask() (or the Legacy process.nextTick() in older code), or expose a start() method.

async listeners without captureRejections

Enable the option, or catch inside the listener; never make an 'error' listener async.

Two await once() in a row for events emitted back to back

Create both promises first and combine them with Promise.all(), or use events.on().

Assuming emit() waits for async listeners

It does not; gather their promises yourself when ordering matters.

Long CPU work inside a listener

It blocks every other listener and the caller of emit(); see Don’t Block the Event Loop.

Using util.inherits() or EventEmitter.call(this) from old tutorials

Use class extends EventEmitter.

Treating EventTarget like EventEmitter

Remember the single event argument, no special 'error', and no prependListener().

What changed recently

Table 6. Notable changes to the events APIs, with versions
Version Change

v25.4.0 and v24.14.0

events.listenerCount() is no longer documentation-deprecated and accepts EventTarget arguments. Code written for Node.js 22 should still prefer emitter.listenerCount() for emitters.

v24.0.0 and v22.16.0

events.addAbortListener() became Stable (it was Experimental).

v23.0.0, v22.1.0 and v20.13.0

CustomEvent is stable and no longer experimental.

v22.0.0 and v20.13.0

events.on() accepts highWaterMark and lowWaterMark, for consistency; the older spelling highWatermark and lowWatermark still works.

v20.0.0

events.on() gained the close option.

v19.9.0 and v18.17.0

events.getMaxListeners() added.

v19.8.0 and v18.16.0

emitter.listenerCount() accepts an optional listener argument.

v20.5.0 and v18.18.0

events.addAbortListener() added.

v17.4.0 and v16.14.0

captureRejections and Symbol.for('nodejs.rejection') are no longer experimental; EventEmitterAsyncResource added.

v16.0.0

EventTarget error handling changed so that listener errors are reported as uncaught exceptions.

v15.4.0

EventTarget is no longer experimental; the signal option of addEventListener() added.

The event APIs are among the most stable parts of Node.js, so there is little to migrate between Node.js 22, 24 and 26: every example on this page except the one using the using declaration printed identical results on all three lines (stack traces aside). The single practical difference is syntax, not API: using needs Node.js 24 or later.

Where the books are outdated

Efficient Node.js (Samer Buna, O’Reilly 2025) introduces EventEmitter in its chapter on asynchrony and events, and Node.js Projects (Jonathan Wexler, O’Reilly 2025) uses emitters through the core modules in its projects. The fundamentals those chapters teach remain correct on all three lines: subclassing, synchronous emission, the fatal unhandled 'error' event, once, prependListener and removeListener, and the built-in emitters. The topics below are the ones a reader will not find in the chapter outlines and should add from the official documentation:

Table 7. Events topics newer than or beyond the books' coverage
Topic Where to learn it

captureRejections and Symbol.for('nodejs.rejection'), stable since v17.4.0 and v16.14.0

Async listeners and captureRejections above.

events.once() and events.on() as promises and async iterators with close, signal and watermark options

events.once() and events.on() above.

events.errorMonitor

The special 'error' event above.

EventTarget, Event, CustomEvent, and NodeEventTarget as the web-compatible alternative

EventTarget, Event and CustomEvent above.

addAbortListener() and events.setMaxListeners() for AbortSignal, and using for scoped listeners (Node.js 24 and later)

addAbortListener and AbortSignal above.

Generic EventEmitter typing with @types/node

Typing events in TypeScript above.

The older tutorials that predate the 2025 books are the ones that need the most care: the pattern util.inherits(MyEmitter, EventEmitter) with EventEmitter.call(this), require('events').EventEmitter, and reading emitter.listeners() expecting once() wrappers (the call has returned the original functions since v7.0.0) are all obsolete style.