Module: Nodejs
Nodejs·123·4 MIN READ

123: Events, EventEmitter, EventTarget, AbortController, and Async Event Design

TOPICS COVERED: 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:

text
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

js
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

js
bus.on('event', () => {
  console.log('listener');
});

console.log('before');
bus.emit('event');
console.log('after');

Output:

text
before
listener
after

This matters if a listener throws.

Listener errors

js
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:

js
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:

text
error

If an EventEmitter emits 'error' with no error listener, Node treats it specially and it can crash the process.

js
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:

js
bus.once('ready', () => {
  console.log('first ready only');
});

Promise helper:

js
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

js
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:

js
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:

js
bus.emit('changed', a, b, c, d);

Better:

js
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:

text
TaskCompleted happened at 10:42

State:

text
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:

text
one eventual success/failure result

EventEmitter:

text
zero/many events over time

Use a Promise for:

js
const task = await loadTask(id);

Use events for:

text
worker progress
socket messages
stream data

AsyncIterator from events

Node event helpers can expose event streams as async iterators in supported APIs.

This enables:

js
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:

text
EventTarget
Event
CustomEvent in supported contexts
AbortSignal

Example:

js
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.

js
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:

text
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:

js
appEvents.emit('order:paid', order);

can decouple modules, but can also hide control flow:

text
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:

js
await chargePayment();
await persistOrder();
await sendReceipt();

when strict sequential control is required.

Progress events

Worker/service:

js
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 failed event;
  • 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.

js
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

js
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

  1. Build EventEmitter with typed-like documented payloads.
  2. Demonstrate synchronous emit ordering.
  3. Reproduce listener leak warning and fix lifecycle.
  4. Use once Promise helper.
  5. Build abortable event consumer.
  6. Compare EventEmitter and EventTarget.
  7. Build progress events for a worker task.
  8. 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.

Official references