JavaScriptEvent Emitter Pattern

Building an EventEmitter

An EventEmitter is the pub/sub pattern in a small, named API: on to subscribe, off to unsubscribe, emit to broadcast. Node's standard library has one (events.EventEmitter) used by streams, HTTP servers and child processes. The browser has EventTarget for the DOM. Both implement the same idea — and both are easy to recreate in fifty lines, which is the best way to internalise how they work.

A minimal implementation

JS
class EventEmitter {
  #listeners = new Map();   // event -> Set of fns

  on(event, fn) {
    if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
    this.#listeners.get(event).add(fn);
    return () => this.off(event, fn);   // returns an unsubscribe helper
  }

  off(event, fn) {
    this.#listeners.get(event)?.delete(fn);
  }

  emit(event, ...args) {
    for (const fn of this.#listeners.get(event) ?? []) {
      fn(...args);
    }
  }
}

A Map keyed by event name holds a Set of listener functions. on adds; off removes; emit iterates and calls. That is the whole pattern.

Using it

JS
const bus = new EventEmitter();

const stop = bus.on("login", user => console.log("welcome", user.name));
bus.on("login", user => console.log("audit:", user.id));

bus.emit("login", { id: 1, name: "Ada" });

stop();   // remove the first listener
bus.emit("login", { id: 2, name: "Lin" });   // only the audit listener fires
welcome Ada
audit: 1
audit: 2
A small extension — once

Listeners that fire exactly once are a common need. Implement once in terms of on and off.

JS
EventEmitter.prototype.once = function (event, fn) {
  const wrapper = (...args) => {
    this.off(event, wrapper);
    fn(...args);
  };
  return this.on(event, wrapper);
};
Error handling

A throwing listener stops the loop and leaves later subscribers un-notified. Decide what your emitter should do — Node throws synchronously by default; many app-level emitters catch and continue.

JS
emit(event, ...args) {
  for (const fn of this.#listeners.get(event) ?? []) {
    try { fn(...args); }
    catch (err) { console.error("listener error for", event, err); }
  }
}
Memory leaks — the classic trap

Forgetting to unsubscribe is the most common bug. Each on holds the listener forever; if the listener captures a big object via closure, that object never gets garbage-collected.

Return an off function
Returning an unsubscribe helper from `on` (as the example above does) is a small API change with a big effect on hygiene — callers don't need to keep a reference to the function to remove it later.
EventEmitter vs DOM EventTarget
  • API names — DOM uses addEventListener / removeEventListener / dispatchEvent; EventEmitter uses on / off / emit.

  • Event payload — DOM listeners receive an Event object. EventEmitter listeners receive whatever arguments emit was given.

  • Bubbling — DOM events bubble through the tree. EventEmitter is flat — one emitter, one set of listeners per name.

  • Default behaviour — DOM events support preventDefault and stopPropagation. EventEmitter is fire-and-forget.

Same pattern with EventTarget

JS
const target = new EventTarget();

target.addEventListener("login", e => console.log(e.detail));
target.dispatchEvent(new CustomEvent("login", { detail: { id: 1 } }));
Node's EventEmitter — the differences worth knowing
  • Has a default cap of 10 listeners per event (emitter.setMaxListeners(n) to change). Helpful for catching forgotten removals.

  • Emits a special 'error' event — emitting it with no listener crashes the process. Always attach an error handler to long-lived emitters.

  • Listeners run synchronously, in the order they were added, on the same tick as emit.

  • Supports prependListener, eventNames, listenerCount and friends for introspection.

When to use an emitter (and when not to)
  • Decoupling components inside a single process — a module raises an event, anyone interested can listen.

  • Stream-shaped APIs — data, end, error are perfect for EventEmitter.

  • NOT a substitute for a message queue across processes — for that, use Redis pub/sub, Kafka, or your platform of choice.

  • NOT a substitute for state management — emitters carry events, not the current value. Pair them with a store if subscribers need the latest state.

Build it once
Writing the 30-line version above demystifies a huge family of libraries — Node streams, web sockets, every reactive store. The pattern is small; the leverage is enormous.