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
An event says:
Something happened.
Examples:
connection opened data arrived task completed worker exited shutdown started
Node core heavily uses events.
Streams, servers, sockets, child processes, and workers expose event-driven behavior.
EventEmitter
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 async work.
Do not assume emitting automatically queues a later event-loop turn.
Synchronous listener behavior
bus.on('event', () => {
console.log('listener');
});
console.log('before');
bus.emit('event');
console.log('after');
Output:
before listener after
This matters if a listener throws.
Listener errors
bus.on('event', () => {
throw new Error('listener failed');
});
That error propagates through the synchronous emit() call unless caught.
EventEmitter does not automatically collect listener errors into a Promise.
If listeners are async:
bus.on('event', async () => {
await doWork();
});
the returned Promise is not automatically awaited by ordinary emit.
Design async event systems deliberately.
The special 'error' event
Many EventEmitter-based Node APIs emit:
error
If an EventEmitter emits 'error' with no error listener, Node treats it specially and it can crash the process.
emitter.on('error', (error) => {
console.error(error);
});
Do not add a meaningless error handler just to suppress failures.
Handle or propagate according to architecture.
once
One listener invocation:
bus.once('ready', () => {
console.log('first ready only');
});
Promise helper:
import { once } from 'node:events';
await once(bus, 'ready');
This can be useful for:
- waiting for drain;
- server listening;
- worker startup.
Understand rejection/error behavior from the documented helper.
Remove listener
function onMessage(message) {
...
}
bus.on('message', onMessage);
bus.off('message', onMessage);
You need the same function identity.
This mirrors DOM event listener cleanup.
Listener leaks
Adding listeners repeatedly without removing:
for (const request of requests) {
bus.on('update', ...);
}
can retain:
- closures;
- request objects;
- users;
- buffers.
Node may warn about high listener counts.
Do not solve a leak by simply increasing max listeners.
Fix lifecycle.
Event payload contracts
Weak:
bus.emit('changed', a, b, c, d);
Better:
bus.emit('task:changed', {
taskId,
actorId,
changes,
occurredAt,
});
A stable object payload evolves more clearly.
Do not include secrets/sensitive objects unnecessarily.
Events versus state
Event:
TaskCompleted happened at 10:42
State:
task.completed = true
If a consumer joins later, an event emitter does not automatically replay current state.
Do not use transient events as the sole source of durable business truth.
Persist state in database/event log according to architecture.
Events versus Promises
Promise:
one eventual success/failure result
EventEmitter:
zero/many events over time
Use a Promise for:
const task = await loadTask(id);
Use events for:
worker progress socket messages stream data
AsyncIterator from events
Node event helpers can expose event streams as async iterators in supported APIs.
This enables:
for await (const [message] of on(emitter, 'message')) {
...
}
with documented cancellation options.
Useful when sequential async consumption is clearer than callback listeners.
Be careful if producer can outpace consumer; buffering/backpressure semantics matter.
EventTarget
Node also implements web-compatible:
EventTarget Event CustomEvent in supported contexts AbortSignal
Example:
const target = new EventTarget();
target.addEventListener('ready', () => {
console.log('ready');
});
target.dispatchEvent(new Event('ready'));
Use EventEmitter for Node ecosystem APIs and EventTarget when web-compatible API design is appropriate.
Do not mix them without a reason.
AbortSignal events
AbortSignal is an EventTarget.
const controller = new AbortController();
controller.signal.addEventListener(
'abort',
() => {
console.log('aborted');
},
{ once: true },
);
controller.abort();
Prefer passing signals to APIs that support them rather than manually subscribing when possible.
Composite cancellation
Modern Node/web APIs can create timeout/composed signals.
Conceptually:
request aborted OR shutdown signal OR deadline exceeded
should cancel work.
Use supported AbortSignal helpers for your Node baseline.
EventEmitter and AsyncLocalStorage
Request context may flow through many async callbacks.
AsyncLocalStorage can associate request ID with event-driven processing when asynchronous resources preserve context.
Test library integrations; do not assume hidden context should carry security-critical identity.
Domain event bus caution
A global event bus:
appEvents.emit('order:paid', order);
can decouple modules, but can also hide control flow:
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/outbox architecture rather than in-memory EventEmitter.
In-memory events disappear when process crashes.
Event ordering
If business correctness depends on listener order, that coupling should be explicit.
Do not rely on registration order spread across modules.
Prefer orchestration:
await chargePayment();
await persistOrder();
await sendReceipt();
when strict sequential control is required.
Progress events
Worker/service:
emitter.emit('progress', {
completed: 42,
total: 100,
});
Consumers can update CLI/UI.
Throttle progress emission for hot loops; emitting millions of progress events can itself become expensive.
Error-first event design
For custom event systems, decide whether failures are:
- emitted as
'error'; - emitted as domain
failedevent; - rejected Promise;
- returned result.
Do not mix all without contract.
Reentrancy
Because emit() is synchronous, a listener can trigger another event before previous emission returns.
bus.on('a', () => {
bus.emit('b');
});
Complex reentrant state mutation can be surprising.
Keep event listeners small and invariants clear.
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.
Worked project: task processor events
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');
}
}
Discuss:
- Should one task failure continue?
- Should error be special
'error'? - How is cancellation handled?
- What if process crashes?
- Should progress be durable?
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.
