Skip to content
Node.js nd events 5 min read

Building Custom EventEmitter Classes

Using a standalone EventEmitter instance is fine for a quick event bus, but the real power shows up when your own classes become emitters. By extending EventEmitter, a class gains emit, on, once, and the rest of the API as part of its public surface. Consumers can then subscribe to meaningful domain events — progress, done, error — without knowing anything about the internals. This is exactly how Node’s own streams, servers, and child processes are built, and it is the cleanest way to decouple a long-running component from the code that watches it.

Extending EventEmitter in a class

To turn a class into an emitter, extend EventEmitter and call super() in the constructor so the emitter machinery is initialised. After that, this.emit(...) inside your methods broadcasts events, and anyone holding an instance can call instance.on(...) to listen.

import { EventEmitter } from 'node:events';

class Thermostat extends EventEmitter {
  #temperature = 20;

  set(value) {
    const previous = this.#temperature;
    this.#temperature = value;
    // Announce a domain event with a useful payload.
    this.emit('change', { previous, current: value });
  }
}

const thermostat = new Thermostat();

thermostat.on('change', ({ previous, current }) => {
  console.log(`Temperature changed from ${previous}°C to ${current}°C`);
});

thermostat.set(23);
thermostat.set(19);

Output:

Temperature changed from 20°C to 23°C
Temperature changed from 23°C to 19°C

The CommonJS form is identical apart from the import:

const { EventEmitter } = require('node:events');

Always call super() before touching this in the constructor. Skipping it leaves the emitter uninitialised and the first emit will throw a reference error.

Emitting domain events

Treat event names as a contract. Pick clear, present-tense or past-tense names (start, progress, complete) and pass a single structured object as the payload so you can add fields later without breaking listeners. emit() accepts any number of arguments, but one well-shaped object ages best.

class Downloader extends EventEmitter {
  async fetch(url) {
    this.emit('start', { url });
    const response = await globalThis.fetch(url);
    const total = Number(response.headers.get('content-length')) || 0;
    const body = await response.arrayBuffer();
    this.emit('progress', { received: body.byteLength, total });
    this.emit('complete', { url, bytes: body.byteLength });
    return body;
  }
}

emit() returns true if the event had at least one listener and false otherwise — handy when you want to know whether anyone is actually watching.

The error event convention

EventEmitter treats 'error' specially. If an emitter emits 'error' and no listener is registered for it, Node throws the error and, by default, crashes the process. This is deliberate: a silently swallowed error is worse than a loud crash. The convention is to always emit an Error instance and to always attach an error listener before starting work.

import { EventEmitter } from 'node:events';

class Parser extends EventEmitter {
  parse(input) {
    try {
      const data = JSON.parse(input);
      this.emit('data', data);
    } catch (cause) {
      this.emit('error', new Error('Invalid JSON', { cause }));
    }
  }
}

const parser = new Parser();
parser.on('error', (err) => console.error('Caught:', err.message));
parser.on('data', (data) => console.log('Parsed:', data));

parser.parse('{ "ok": true }');
parser.parse('not json');

Output:

Parsed: { ok: true }
Caught: Invalid JSON

Remove the error listener above and the second parse call will throw an uncaught exception that terminates the process. Subscribe to error first, always.

Designing an event-driven component

A job runner is a textbook case: it processes work over time and the caller wants to observe progress without polling. The runner emits lifecycle events; the consumer reacts. Note how error events are isolated per task so one bad job does not stop the rest.

import { EventEmitter } from 'node:events';

class JobRunner extends EventEmitter {
  #queue = [];

  add(job) {
    this.#queue.push(job);
    return this;
  }

  async run() {
    this.emit('start', { count: this.#queue.length });
    let done = 0;

    for (const [index, job] of this.#queue.entries()) {
      try {
        const result = await job();
        done++;
        this.emit('job:done', { index, result, done });
      } catch (cause) {
        this.emit('error', new Error(`Job ${index} failed`, { cause }));
      }
    }

    this.emit('finish', { done, total: this.#queue.length });
  }
}

const runner = new JobRunner();

runner
  .on('start', ({ count }) => console.log(`Running ${count} jobs...`))
  .on('job:done', ({ index }) => console.log(`Job ${index} ok`))
  .on('error', (err) => console.error(err.message))
  .on('finish', ({ done, total }) => console.log(`Finished ${done}/${total}`));

runner
  .add(async () => 'a')
  .add(async () => { throw new Error('boom'); })
  .add(async () => 'c');

await runner.run();

Output:

Running 3 jobs...
Job 0 ok
Job 1 failed
Job 2 ok
Finished 2/3

Because the chainable add and on methods return this, the API reads fluently — another small benefit of owning the emitter rather than wrapping one.

Inheritance vs composition

ApproachWhen to useTrade-off
extends EventEmitterThe class is fundamentally event-driven (streams, runners)Couples your public API to the emitter surface
Internal emitter fieldYou want events but a narrow, curated APIMore wiring; must re-expose on/off yourself
EventTargetWeb-compatible / AbortSignal interopDifferent API (addEventListener, CustomEvent)

Reach for inheritance when emitting events is core to the object’s identity; otherwise hold a private EventEmitter and expose only the methods you intend to support.

Best Practices

  • Call super() first in the constructor, before any use of this.
  • Document your event names and payload shapes — they are a public contract.
  • Always emit an Error instance for 'error', and ensure callers can subscribe before work begins.
  • Pass a single structured object as the payload so you can extend it without breaking listeners.
  • Use namespaced names like job:done to group related lifecycle events.
  • Call setMaxListeners() (or pass { captureRejections: true }) deliberately rather than ignoring the leak warning at 10+ listeners.
  • Prefer a private emitter field over inheritance when you want to keep the public API small.
Last updated June 14, 2026
Was this helpful?