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:
detailis 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 adoptedEventTargetfor 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).
| Option | Type | Effect |
|---|---|---|
once | boolean | Listener auto-removes after firing one time. |
signal | AbortSignal | Removes the listener when the signal aborts. |
passive | boolean | Accepted for spec compatibility; preventDefault becomes a no-op. |
capture | boolean | Part 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.
| Feature | EventTarget | EventEmitter |
|---|---|---|
| Register | addEventListener(type, fn) | on(name, fn) / addListener |
| Emit | dispatchEvent(new Event()) | emit(name, ...args) |
| Payload | single Event object (detail) | any number of positional args |
| One-shot | { once: true } option | once(name, fn) |
| Abort integration | native via signal | manual (removeListener) |
'error' semantics | no special handling | uncaught 'error' throws |
| Inspect listeners | none | listenerCount, eventNames |
| Max-listener warning | none | yes (setMaxListeners) |
| Performance | slightly slower (object allocation) | faster for hot paths |
| Portability | browser + Node | Node 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/AbortSignaland 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
Eventallocation.
Warning: Don’t mix the two on one object expecting cross-compatibility. An
EventEmitterdoes not understandaddEventListener, and anEventTargetdoes not understand.on(). Pick one interface per object.
Best Practices
- Always carry custom data in
CustomEvent’sdetailfield; 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 anAbortControllerto 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
EventTargethas no'error'magic — add explicit error handling inside listeners since exceptions surface asuncaughtException. - Choose
EventTargetfor portable/browser-shared code andEventEmitterfor Node-only modules that benefit from its richer API.