React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nodejseventseventemitterpub-subtypescript

EventEmitter Patterns

Use Node.js EventEmitter for publish-subscribe patterns, with typed events and async handlers.

Recipe

Quick-reference recipe card — copy-paste ready.

import { EventEmitter } from "node:events";
 
const emitter = new EventEmitter();
 
// Subscribe
emitter.on("message", (text: string) => {
  console.log("got:", text);
});
 
// One-shot subscribe
emitter.once("ready", () => {
  console.log("ready fired once");
});
 
// Unsubscribe
const handler = (n: number) => console.log(n);
emitter.on("tick", handler);
emitter.off("tick", handler);
 
// Publish
emitter.emit("message", "hello");
emitter.emit("ready");
 
// Always handle errors — unhandled 'error' crashes the process
emitter.on("error", (err) => {
  console.error("emitter error:", err);
});
emitter.emit("error", new Error("boom"));

When to reach for this: Decoupling producers from consumers inside a single Node process — job queues, log pipelines, stream lifecycle, plugin hooks.

Working Example

A typed EventEmitter wrapper class for a job queue.

import { EventEmitter } from "node:events";
 
// Type map: event name to argument tuple
interface JobEvents {
  "job:start": [id: string];
  "job:progress": [id: string, percent: number];
  "job:complete": [id: string, result: unknown];
  "job:error": [id: string, err: Error];
}
 
class JobQueue extends EventEmitter {
  override on<K extends keyof JobEvents>(
    event: K,
    listener: (...args: JobEvents[K]) => void
  ): this {
    return super.on(event, listener as (...args: unknown[]) => void);
  }
 
  override emit<K extends keyof JobEvents>(
    event: K,
    ...args: JobEvents[K]
  ): boolean {
    return super.emit(event, ...args);
  }
 
  async run(id: string, task: (report: (p: number) => void) => Promise<unknown>) {
    this.emit("job:start", id);
    try {
      const result = await task((percent) => {
        this.emit("job:progress", id, percent);
      });
      this.emit("job:complete", id, result);
    } catch (err) {
      this.emit("job:error", id, err as Error);
    }
  }
}
 
const queue = new JobQueue();
 
queue.on("job:start", (id) => console.log(`[${id}] start`));
queue.on("job:progress", (id, pct) => console.log(`[${id}] ${pct}%`));
queue.on("job:complete", (id, result) => console.log(`[${id}] done`, result));
queue.on("job:error", (id, err) => console.error(`[${id}] failed`, err));
 
await queue.run("job-1", async (report) => {
  for (let i = 0; i <= 100; i += 25) {
    report(i);
    await new Promise((r) => setTimeout(r, 50));
  }
  return { ok: true };
});

What this demonstrates:

  • Typed overrides for on and emit give full autocomplete and payload safety.
  • Error events are explicit — no silent failures.
  • The producer (run) and consumers (the on handlers) are fully decoupled.

Deep Dive

How It Works

EventEmitter keeps an internal map of event name to an array of listener functions. emit iterates that array synchronously, calling each listener in the order it was registered. There is no built-in queue and no backpressure — listeners run on the current tick unless they themselves schedule async work.

on appends, once appends a wrapper that removes itself after the first call, off (alias removeListener) removes by reference, and removeAllListeners(event?) clears everything.

The 'error' event is special: if nothing is listening when you emit('error', err), Node throws the error and, if uncaught, terminates the process.

Variations

Typed EventEmitter with generics. The JobQueue pattern above is the idiomatic approach in modern Node + TypeScript. Libraries like tsee or typed-emitter package the same idea.

Async iteration with events.on(). Consume events like a stream:

import { on, EventEmitter } from "node:events";
 
const emitter = new EventEmitter();
 
setTimeout(() => emitter.emit("data", 1), 10);
setTimeout(() => emitter.emit("data", 2), 20);
 
for await (const [value] of on(emitter, "data")) {
  console.log("value:", value);
  if (value === 2) break;
}

once as a Promise. Great for waiting on a single lifecycle event:

import { once, EventEmitter } from "node:events";
 
const emitter = new EventEmitter();
setTimeout(() => emitter.emit("ready", "ok"), 50);
 
const [value] = await once(emitter, "ready");
console.log(value); // "ok"

Extending EventEmitter. Subclass when the emitter is the primary identity of your object (the JobQueue example). Compose (have an internal #emitter) when events are a secondary concern.

Removing all listeners. emitter.removeAllListeners() with no argument nukes every event; with an argument, only that event. Handy in teardown but dangerous if you do not own the emitter.

Max listeners warning. Node prints MaxListenersExceededWarning at 11 listeners per event. Raise with emitter.setMaxListeners(50) or globally via EventEmitter.defaultMaxListeners. The warning is a leak hint, not an error.

TypeScript Notes

Define an event type map as an interface where each key is the event name and each value is a tuple of the listener arguments — for example interface Events \{ start: [id: string]; progress: [percent: number] \}. Use this map with a generic base class like TypedEmitter<Events> (from typed-emitter), or write your own overrides of on, once, off, and emit that constrain the event name with K extends keyof Events and the args with Events[K]. This gives you autocomplete on event names and compile-time checking of payload shapes.

Gotchas

  1. Forgetting error handlers crashes the process. Emitting 'error' with no listener throws. Always attach an error listener before any code path that can emit one.
  2. MaxListeners warning at 10. Adding the same handler in a loop, or subscribing inside a React effect without cleanup, quickly exceeds the default. The warning is printed once per emitter per event — easy to miss.
  3. emit is synchronous. Listeners run in registration order on the same tick. A slow synchronous listener blocks every other listener and the event loop.
  4. once does not prevent other registrations. It only wraps that one handler. Other on listeners keep firing forever.
  5. Anonymous listeners cannot be removed. emitter.off("x", () => {}) passes a brand-new function reference and removes nothing. Store the handler in a variable if you ever need to remove it.
  6. Throwing inside a listener. A synchronous throw propagates out of emit and can take down unrelated listeners. Wrap risky work in try/catch or use async handlers that reject into an error event.
  7. Async listeners are fire-and-forget. emit does not await anything. Rejected promises in listeners become unhandled rejections unless you attach .catch inside the listener.

Alternatives

OptionBest ForNotes
node:events EventEmitterNode-only servicesBuilt in, zero deps, sync emit.
EventTarget / CustomEventUniversal (Node 19+, browsers, workers)Web standard, slightly more verbose, supports AbortSignal for cleanup.
mittTiny universal pub-sub~200 bytes, no wildcard, no once — just on/off/emit.
nanoeventsSmall typed emitterFirst-class TypeScript generics, returns an unsubscribe function.
RxJS SubjectStreams, operators, backpressureHeavy, but unmatched for complex async pipelines.
Node streamsByte/object streams with backpressureUse when order and flow control matter, not just signaling.

FAQs

What happens if I emit an error event with no listener?

Node throws the error synchronously out of emit. If nothing catches it, the process crashes with an Unhandled 'error' event message. Always attach an error listener before the first emit.

Is emit synchronous or asynchronous?

Synchronous. Listeners run in registration order on the current tick, blocking the event loop until they return. Async listeners are fire-and-forget — emit does not await them.

How do I remove a listener I added with once?

Save the reference and pass it to off: const h = () => {}; emitter.once("x", h); emitter.off("x", h). You can also call removeAllListeners("x") if you own the emitter.

Why does Node warn about 11 listeners?

MaxListenersExceededWarning fires at 11 listeners on a single event — a heuristic for detecting leaks. If you legitimately need more, call emitter.setMaxListeners(50) or set EventEmitter.defaultMaxListeners globally.

How do I type events in TypeScript without a library? (TypeScript)

Define an interface mapping event names to argument tuples, then subclass EventEmitter and override on and emit with generic signatures constrained by K extends keyof YourEvents. See the JobQueue working example on this page.

Should I extend EventEmitter or compose it? (TypeScript)

Extend when events are the object's primary identity (a bus, a queue, a stream). Compose with a private #emitter field when events are a secondary concern — that keeps your public API smaller and avoids exposing every EventEmitter method.

Can I await an event?

Yes — use events.once(emitter, "name") which returns a promise resolving to the emitted arguments as an array. It also rejects on 'error', which makes it safer than a hand-rolled wrapper.

How do I consume events as a stream?

Use events.on(emitter, "name") which returns an async iterator. You can for await over it and break to stop listening. Pair with an AbortSignal option for clean cancellation.

What is the difference between off and removeListener?

None — off is an alias added for parity with the DOM. Both remove a listener by reference.

Why does my anonymous listener keep firing after I tried to remove it? (Gotcha)

emitter.off("x", () => {}) creates a brand-new function and passes it to off, which finds no match. Store the handler in a variable or use once if you only need it one time.

What should I use instead of EventEmitter in the browser? (Gotcha)

EventTarget with CustomEvent is the web standard and works everywhere, including Node 19+. For ultra-small universal pub-sub, reach for mitt or nanoevents. Do not ship node:events to the browser.

How do I prevent unhandled rejections from async listeners?

Wrap the async body in a try/catch and forward failures to the emitter: emitter.on("x", async (v) => { try { await work(v); } catch (err) { emitter.emit("error", err); } }). emit will not await your handler, so you must catch inside.