Skip to content
Node.js nd events 4 min read

EventTarget & CustomEvent in Node.js

Node.js ships two event systems. The classic EventEmitter is Node-native and battle-tested, but Node also implements the browser’s EventTarget and CustomEvent interfaces as globals. These web-standard APIs let you write event-driven code that behaves identically in the browser and on the server, which matters for isomorphic libraries and for interop with Web APIs like AbortController, fetch, and streams. This page explains how EventTarget/CustomEvent work in Node, how they differ from EventEmitter, and when to reach for each.

The web-standard event model

EventTarget is the foundation of the DOM event system. Instead of emitter.on('x', fn) you call target.addEventListener('x', fn), and instead of emitter.emit('x', data) you dispatch an Event (or CustomEvent) object. The listener receives a single Event object — never positional arguments. Both EventTarget and CustomEvent have been stable, non-experimental globals since Node.js 16, so no import is required.

const target = new EventTarget();

target.addEventListener('greet', (event) => {
  console.log(`Listener fired: ${event.type}`);
});

target.dispatchEvent(new Event('greet'));

Output:

Listener fired: greet

Because these are globals, the same snippet runs unchanged in a browser, a Web Worker, Deno, or Node.

Passing data with CustomEvent

A plain Event carries only its type. To attach a payload, use CustomEvent and read it back from the detail property. This mirrors the browser API exactly.

const bus = new EventTarget();

bus.addEventListener('order:created', (event) => {
  const { id, total } = event.detail;
  console.log(`Order ${id} for $${total}`);
});

bus.dispatchEvent(
  new CustomEvent('order:created', {
    detail: { id: 'A-1001', total: 49.99 },
  })
);

Output:

Order A-1001 for $49.99

Tip: detail is the only sanctioned channel for custom data. Don’t bolt extra properties onto the event object — non-standard fields won’t survive in browsers and break the portability you adopted EventTarget for in the first place.

Listener options

addEventListener accepts an options object as its third argument. The most useful options for server code are once, signal, and capture (capture is largely a no-op outside a DOM tree but is accepted for compatibility).

OptionTypeEffect
oncebooleanListener auto-removes after firing one time.
signalAbortSignalRemoves the listener when the signal aborts.
passivebooleanAccepted for spec compatibility; preventDefault becomes a no-op.
capturebooleanPart of the listener identity; matters for removeEventListener.

The signal integration is the standout feature — one AbortController can tear down many listeners across many targets at once.

const controller = new AbortController();
const { signal } = controller;
const target = new EventTarget();

target.addEventListener('tick', () => console.log('tick'), { signal });
target.addEventListener('tock', () => console.log('tock'), { signal });

target.dispatchEvent(new Event('tick'));
controller.abort(); // both listeners removed in one call
target.dispatchEvent(new Event('tick')); // ignored

Output:

tick

Removing listeners

To remove a listener with removeEventListener, you must pass the same function reference and a matching capture flag. Anonymous inline functions cannot be removed — keep a named reference if you intend to detach later.

const target = new EventTarget();
const onPing = () => console.log('pong');

target.addEventListener('ping', onPing);
target.dispatchEvent(new Event('ping'));

target.removeEventListener('ping', onPing);
target.dispatchEvent(new Event('ping')); // no output

Output:

pong

EventTarget vs. EventEmitter

The two APIs solve the same problem with different ergonomics. EventEmitter is richer and more Node-idiomatic; EventTarget is leaner and portable.

FeatureEventTargetEventEmitter
RegisteraddEventListener(type, fn)on(name, fn) / addListener
EmitdispatchEvent(new Event())emit(name, ...args)
Payloadsingle Event object (detail)any number of positional args
One-shot{ once: true } optiononce(name, fn)
Abort integrationnative via signalmanual (removeListener)
'error' semanticsno special handlinguncaught 'error' throws
Inspect listenersnonelistenerCount, eventNames
Max-listener warningnoneyes (setMaxListeners)
Performanceslightly slower (object allocation)faster for hot paths
Portabilitybrowser + NodeNode only

A key behavioral difference: EventEmitter treats an 'error' event specially — emitting one with no listener throws and can crash the process. EventTarget has no such rule; an error event with no listener is silently dropped. Likewise, listeners on an EventTarget that throw do not stop the other listeners, but the error is reported to process.on('uncaughtException').

When to use each

Reach for EventTarget/CustomEvent when:

  • You ship code that must run in both Node and the browser (isomorphic packages, SDKs).
  • You’re already wiring up AbortController/AbortSignal and want unified cancellation.
  • You want a minimal, dependency-free pub/sub bus with no Node-specific surface.

Prefer EventEmitter when:

  • You’re writing server-only code and want multi-argument emits, once(), and introspection helpers.
  • You rely on the special 'error' event contract for fail-fast behavior.
  • The event path is extremely hot and you want to avoid per-dispatch Event allocation.

Warning: Don’t mix the two on one object expecting cross-compatibility. An EventEmitter does not understand addEventListener, and an EventTarget does not understand .on(). Pick one interface per object.

Best Practices

  • Always carry custom data in CustomEvent’s detail field; never attach ad-hoc properties to the event.
  • Keep a named function reference for any listener you intend to remove later.
  • Use the { signal } option with an AbortController to remove listeners in bulk instead of tracking each one manually.
  • Namespace event types (order:created, user:login) to avoid collisions on a shared bus.
  • Remember EventTarget has no 'error' magic — add explicit error handling inside listeners since exceptions surface as uncaughtException.
  • Choose EventTarget for portable/browser-shared code and EventEmitter for Node-only modules that benefit from its richer API.
Last updated June 14, 2026
Was this helpful?