123: Events, EventEmitter, EventTarget, AbortController, and Async Event Design
Learning objectives
You will learn to:
- understand Node's event-driven architecture;
- use
EventEmitter; - distinguish events from Promises and state;
- understand special
'error'behavior; - manage listeners and prevent leaks;
- use
once; - consume events with Promise helpers and async iterators where appropriate;
- understand
EventTarget; - use AbortSignal for event cancellation;
- design event payload contracts;
- avoid turning an EventEmitter into an invisible application-wide event bus.
Event-driven model
When a component needs to tell other code that something has happened, it can publish an event rather than call every consumer directly. The event is a notification, not necessarily a request for a result:
Something happened.
Examples include:
connection opened
data arrived
task completed
worker exited
shutdown started
Node core uses this model extensively. Streams, servers, sockets, child processes, and workers all expose behavior through events. Once you work with these APIs, event-driven code is not an optional style choice; it is part of how the runtime communicates asynchronous activity.
EventEmitter
EventEmitter is Node's traditional mechanism for registering listeners and emitting named notifications. A listener subscribes to an event name, and an emitter invokes the listeners when that name is emitted.
import { EventEmitter } from 'node:events';
const bus = new EventEmitter();
bus.on('task:created', (task) => {
console.log('created', task.id);
});
bus.emit('task:created', {
id: 't1',
title: 'Learn events',
});
emit() synchronously invokes listeners in registration order unless listeners themselves schedule asynchronous work. In this example, the listener runs as part of the emit() call; emit() does not return first and then arrange for the listener to run on a later event-loop turn.
That distinction is easy to miss because the events often represent asynchronous activities such as network I/O. The activity that led to the event may have been asynchronous, but dispatching the event itself is synchronous.
Synchronous listener behavior
The ordering becomes obvious in a small example:
bus.on('event', () => {
console.log('listener');
});
console.log('before');
bus.emit('event');
console.log('after');
Output:
before
listener
after
The listener's log appears between the two surrounding logs because emit() does not finish until its synchronous listeners have been invoked. This matters when a listener throws, mutates shared state, or emits another event while the first emission is still in progress.
Listener errors
A synchronous listener exception propagates through the call to emit() unless your code catches it:
bus.on('event', () => {
throw new Error('listener failed');
});
EventEmitter does not automatically collect listener errors into a Promise. The caller that emitted the event must therefore understand whether listener failures are allowed to escape, caught at a boundary, or represented through a separate error contract.
Async listeners have a second failure mode:
bus.on('event', async () => {
await doWork();
});
Ordinary emit() does not automatically await the Promise returned by this listener. The listener starts, emit() continues synchronously, and a later rejection is not magically turned into a rejection of emit(). Design async event systems deliberately: decide how asynchronous work is observed, how failures are reported, and whether the producer should use an explicit Promise-based API instead.
The special 'error' event
Many Node APIs built on EventEmitter use the event name below for failures:
error
'error' is special. If an EventEmitter emits 'error' and has no error listener, Node treats that as an unhandled emitter error, and it can crash the process.
emitter.on('error', (error) => {
console.error(error);
});
The handler must be meaningful. Do not add an empty or meaningless error listener just to suppress a crash or warning. Handle the failure, convert it into an appropriate application result, or propagate it according to the architecture. A process-wide failure boundary may log and terminate intentionally; another component may be able to recover. The event name alone does not decide that policy.
once
Some lifecycle notifications should be consumed only once. Registering with once removes the listener after its first invocation:
bus.once('ready', () => {
console.log('first ready only');
});
For a one-time wait, Node also provides a Promise helper:
import { once } from 'node:events';
await once(bus, 'ready');
This style is useful when code is already expressed as an async function and needs to wait for drain, server listening, or worker startup. Understand the helper's documented rejection and error behavior before using it in a critical lifecycle path. In particular, waiting for an event is not the same as guaranteeing that the operation which should produce that event succeeded.
Remove listener
Long-lived emitters need explicit listener cleanup when a consumer's lifecycle ends:
function onMessage(message) {
...
}
bus.on('message', onMessage);
bus.off('message', onMessage);
off() needs the same function identity that was passed to on(). Creating an equivalent new function is not enough, because the emitter cannot use it to locate the original registration.
This is the same basic rule as DOM event listener cleanup. Keep the listener reference when you need to remove it, or use a one-time listener or supported cancellation option when that better represents the lifecycle.
Listener leaks
The following pattern adds another listener for every request:
for (const request of requests) {
bus.on('update', ...);
}
If those listeners are not removed, the emitter retains their closures. Those closures may retain request objects, users, buffers, or other data that should have become collectible. The behavior can also become incorrect: one update may be handled once per old request rather than once for the current request.
Node may warn when an emitter accumulates a high listener count. Treat that warning as evidence to investigate, not as a capacity limit to bypass. Do not solve a leak by simply increasing max listeners. Fix the lifecycle: determine when a subscription ends and remove it, use once where appropriate, or redesign the ownership of the emitter.
Event payload contracts
An event's payload is an API contract between the producer and every listener. Positional arguments become difficult to understand and extend as the event evolves:
Weak:
bus.emit('changed', a, b, c, d);
A named object communicates the fields and leaves room for compatible additions:
Better:
bus.emit('task:changed', {
taskId,
actorId,
changes,
occurredAt,
});
A stable object payload evolves more clearly because each field has a name. Define which fields are required, what their types and units mean, and whether consumers may mutate referenced values. Passing immutable data or treating the payload as read-only prevents one listener from changing what another listener observes.
Treat changes to event names and payload fields as changes to an API. Consumers may live in different modules, processes, or deployment versions, so an apparently small rename can break delivery or interpretation.
Do not include secrets or sensitive objects unnecessarily. Events often have more consumers than the code that emits them makes obvious, so a broad payload can accidentally widen access to credentials, personal data, or mutable internal state.
Events versus state
An event reports an occurrence at a point in time:
Event:
TaskCompleted happened at 10:42
State describes the current fact:
State:
task.completed = true
The useful distinction is what happens when a consumer joins later. An EventEmitter does not automatically replay the current state to a new listener. A listener that was not present at 10:42 may never learn about TaskCompleted.
For that reason, do not use transient events as the sole source of durable business truth. Persist state in a database or event log according to the architecture, and use in-memory events as notifications or coordination where losing them is acceptable.
In practice, a listener may use an event to invalidate a cache or refresh a view, then read the authoritative state from storage. That makes the event a useful trigger without pretending that every consumer observed every historical transition.
Events versus Promises
Promises and events answer different questions. A Promise represents one eventual success or failure result:
Promise:
one eventual success/failure result
An EventEmitter represents zero or many notifications over time:
EventEmitter:
zero/many events over time
Use a Promise when the caller needs one result:
const task = await loadTask(id);
Use events when the consumer needs a sequence or ongoing stream of activity:
worker progress
socket messages
stream data
An API can use both, but give them distinct responsibilities. For example, a method may return a Promise for the worker's eventual completion while exposing progress events separately. Do not force a caller to listen for an event merely to obtain a single result that could be returned directly.
AsyncIterator from events
Callback listeners are not the only way to consume events. In supported Node APIs, event helpers can expose an event stream as an async iterator:
for await (const [message] of on(emitter, 'message')) {
...
}
This can make sequential asynchronous consumption clearer than nested callback logic, and the documented cancellation options let the consumer stop waiting when its own work is finished.
There is still a producer-consumer relationship to manage. If the producer can outpace the consumer, buffering and backpressure semantics matter. An async iterator does not by itself make an unbounded event source safe. Check the API's documented behavior and decide whether to limit, pause, drop, or otherwise control queued work.
EventTarget
Node also implements web-compatible event primitives:
EventTarget
Event
CustomEvent in supported contexts
AbortSignal
The basic API looks like this:
const target = new EventTarget();
target.addEventListener('ready', () => {
console.log('ready');
});
target.dispatchEvent(new Event('ready'));
Use EventEmitter for APIs that fit the Node ecosystem and its conventions. Use EventTarget when a web-compatible API surface is appropriate, especially where browser and server implementations should share the same event model. They are related but not interchangeable APIs, so do not mix them without a reason. Their method names, event objects, error behavior, and supported options should be checked against the API you are actually using.
AbortSignal events
AbortSignal is an EventTarget. An AbortController owns the signal and can dispatch the one-time abort notification:
const controller = new AbortController();
controller.signal.addEventListener(
'abort',
() => {
console.log('aborted');
},
{ once: true },
);
controller.abort();
Prefer passing signals directly to APIs that support them rather than manually subscribing whenever possible. The receiving API can then perform its own cleanup and report cancellation consistently. A listener is still useful when custom work must respond to the signal, but it should have a clear lifecycle and should not remain attached after the operation ends.
Composite cancellation
Cancellation often has more than one cause. Modern Node and web APIs can create timeout or composed signals so that any relevant condition stops the work.
Conceptually:
request aborted
OR
shutdown signal
OR
deadline exceeded
should cancel work.
Use the supported AbortSignal helpers for your Node baseline. Do not assume that every runtime version exposes the same helper, and do not silently treat a timeout, client disconnect, and process shutdown as identical if the application needs to distinguish them for logging or response handling.
Cancellation should also be observable at the operation boundary: callers need to know whether work completed, failed, or stopped because its signal was aborted. Stopping a callback is only part of cancellation if the underlying operation can continue consuming resources.
EventEmitter and AsyncLocalStorage
Request context may flow through many asynchronous callbacks. AsyncLocalStorage can associate a request ID with event-driven processing when the relevant asynchronous resources preserve that context.
That does not make context propagation universal or automatically correct across every library integration. Test the integrations you depend on. Also, do not assume that hidden context should carry security-critical identity: authorization decisions should use explicit, validated inputs and a trustworthy boundary rather than relying only on ambient context.
Domain event bus caution
A global event bus can make modules appear loosely coupled:
appEvents.emit('order:paid', order);
But it can also hide the control flow that a maintainer needs to understand. Before using such a bus for a business-critical path, answer these questions:
who listens?
in what order?
what if listener fails?
is delivery guaranteed?
is it durable?
does retry duplicate side effects?
For critical cross-service business events, use a durable message broker or an outbox architecture rather than an in-memory EventEmitter. The outbox or broker provides a place to reason about persistence, delivery, retries, and duplication. In-memory events disappear when the process crashes, so they cannot substitute for durable delivery.
Event ordering
EventEmitter invokes listeners in registration order, but relying on registration order spread across modules makes business correctness depend on hidden coupling. If the order matters, make that dependency explicit.
Prefer orchestration when strict sequential control is required:
await chargePayment();
await persistOrder();
await sendReceipt();
This code makes the sequence, the awaited failures, and the owner of the workflow visible. Events remain useful for independent notifications, but they are a poor substitute for an explicit workflow when each step depends on the previous step.
Progress events
A worker or service can publish progress for a CLI, UI, or monitoring consumer:
emitter.emit('progress', {
completed: 42,
total: 100,
});
The object payload gives consumers enough information to render progress without knowing the worker's internal counters. Decide whether progress is advisory or durable; a consumer that connects late may miss earlier updates, which is often acceptable for display but not for business state.
Throttle progress emission for hot loops. Emitting millions of progress events can itself consume meaningful CPU and memory, compete with the work being measured, and overwhelm consumers. Emit at useful intervals or meaningful milestones instead of treating every internal iteration as a public event.
Error-first event design
For a custom event system, choose a failure contract before adding listeners. Failures might be:
- emitted as
'error'; - emitted as domain
failedevent; - rejected Promise;
- returned result.
Each option can be valid in the right API. What causes trouble is mixing them without a contract, leaving consumers unsure whether they must add an error listener, catch a Promise, inspect a result, or handle several paths at once. Document what a producer guarantees and what happens after failure, including whether later events can still be emitted.
Reentrancy
Because emit() is synchronous, a listener can trigger another event before the previous emission returns:
bus.on('a', () => {
bus.emit('b');
});
While handling a, the emitter immediately enters the listeners for b. Complex state mutation across these nested emissions can be surprising, particularly if listeners assume that all a listeners have finished before any b listener runs.
Keep event listeners small and keep the invariants around shared state clear. If a workflow is easier to reason about as explicit sequential code, use that instead of creating a chain of implicit reentrant events.
Common mistakes
- assuming EventEmitter is asynchronous;
- async listener rejection not observed;
- no error listener on core emitter where required;
- listener leaks;
- global event bus for durable business events;
- using events when caller needs one result;
- relying on listener order;
- passing mutable objects that listeners mutate;
- unbounded event buffering.
These mistakes are related, but they have different remedies. Confirm whether the problem is dispatch timing, Promise ownership, error policy, subscription lifecycle, durability, mutability, ordering, or buffering before choosing a fix.
Worked project: task processor events
The following processor emits a start notification, reports successful task progress, and emits a domain-specific task error while continuing to the next task:
class TaskProcessor extends EventEmitter {
async run(tasks) {
this.emit('start', {
total: tasks.length,
});
for (const [index, task] of tasks.entries()) {
try {
await processTask(task);
this.emit('progress', {
taskId: task.id,
completed: index + 1,
total: tasks.length,
});
} catch (error) {
this.emit('task:error', {
taskId: task.id,
error,
});
}
}
this.emit('finish');
}
}
This is a useful starting design, not a complete production policy. run() awaits each task, but it does not await the Promises returned by event listeners. A task:error event is also not the same as the special 'error' event, so the process will not receive EventEmitter's special unhandled-error behavior for that event name.
Discuss:
- Should one task failure continue?
- Should error be special
'error'? - How is cancellation handled?
- What if process crashes?
- Should progress be durable?
The answers determine the event contract. For example, cancellation may require an AbortSignal, a process crash may require durable task state, and durable progress may belong in a database or message system rather than only in these in-memory notifications.
Before shipping this design, document which events are informational, which failures stop the run, and whether finish means that every task succeeded or only that the loop has ended. Those meanings should not be left for each consumer to infer.
Exercises
- Build EventEmitter with typed-like documented payloads.
- Demonstrate synchronous emit ordering.
- Reproduce listener leak warning and fix lifecycle.
- Use
oncePromise helper. - Build abortable event consumer.
- Compare EventEmitter and EventTarget.
- Build progress events for a worker task.
- Explain why an in-memory event bus cannot replace a durable message queue.
Mastery checklist
Explain:
- EventEmitter;
- synchronous emit;
'error';- once/off;
- listener lifecycle;
- event payload design;
- events versus state/Promise;
- EventTarget;
- AbortSignal;
- durable versus in-memory events.
