ESC

Type to search the knowledge base.

Event Emitter Pattern

Implement on/off/emit with Map of Sets — once, error isolation, memory leaks, and how this differs from DOM events.

intermediate3 min read
  • javascript
  • event-emitter
  • pub-sub
  • patterns

An event emitter is an in-memory pub/sub object: subscribe with on, publish with emit, unsubscribe with off. Node’s EventEmitter popularized the API; frontends reimplement tiny versions for stores, WebSocket clients, and cross-widget signals without dragging in the DOM.

Minimal implementation

class Emitter {
  #events = new Map(); // type → Set<fn>

  on(type, fn) {
    if (!this.#events.has(type)) this.#events.set(type, new Set());
    this.#events.get(type).add(fn);
    return () => this.off(type, fn); // handy unsubscribe
  }

  off(type, fn) {
    this.#events.get(type)?.delete(fn);
  }

  once(type, fn) {
    const wrap = (...args) => {
      this.off(type, wrap);
      fn(...args);
    };
    return this.on(type, wrap);
  }

  emit(type, ...args) {
    const fns = [...(this.#events.get(type) ?? [])];
    for (const fn of fns) {
      try {
        fn(...args);
      } catch (err) {
        console.error(`handler error for ${type}`, err);
      }
    }
  }

  listenerCount(type) {
    return this.#events.get(type)?.size ?? 0;
  }
}
const bus = new Emitter();
const stop = bus.on('login', (user) => console.log(user.id));
bus.emit('login', { id: 1 });
stop();

Copy the Set before iterating so off inside a handler doesn’t skip siblings.

once, prepend, async

// async handlers — emit usually doesn't await
bus.on('save', async (doc) => {
  await api.save(doc); // errors become unhandled rejections unless you wrap
});

// better: document that handlers are sync, or
async function emitAsync(type, ...args) {
  const fns = [...(this.#events.get(type) ?? [])];
  await Promise.all(fns.map((fn) => fn(...args)));
}

Memory leaks

Long-lived emitters (singleton stores, window-level buses) retain handlers that closed over components:

// React
useEffect(() => {
  return bus.on('tick', handleTick); // if on returns off
}, []);

Always unsubscribe on teardown. Prefer returning an off function from on.

DOM events vs emitter

DOM Emitter
Propagation, capture Flat topic list
Tied to nodes Free-floating
Browser-defined types Your strings
GC with node Manual off

Use DOM custom events when the hierarchy matters. Use an emitter for app services.

Interview whiteboard extras

  • Wildcard events (on('*'))
  • removeAllListeners
  • Max listeners warning (Node)
  • Don’t mutate the listener set while iterating without a snapshot
// wildcard sketch
emit(type, ...args) {
  this.#fire(type, args);
  this.#fire('*', [type, ...args]);
}

Interview answer

“An event emitter maps event names to listener sets with on/off/emit. I snapshot listeners before emit, isolate handler errors, and return unsubscribe functions to avoid leaks. once wraps and removes itself. I pick emitters for app-level signals and DOM events for UI tree communication.”

Testing and typing events

// allow awaiting first event in tests
function oncePromise(emitter, type) {
  return new Promise((resolve) => emitter.once(type, resolve));
}

await oncePromise(bus, 'ready');

Document the event map:

/**
 * @typedef {{ login: [user: {id:string}], logout: [] }} AppEvents
 */

In TypeScript, generic Emitter<AppEvents> with on<K extends keyof AppEvents> prevents typo event names. For production, cap listener counts on singleton buses and log when exceeded — leaked React effects show up as unbounded growth.

Error isolation vs fail-fast

emit(type, ...args) {
  const fns = [...(this.#events.get(type) ?? [])];
  for (const fn of fns) fn(...args); // one throw skips the rest
}

Product buses usually catch per-handler so one bad subscriber doesn’t kill others. Infrastructure code might prefer fail-fast. Pick deliberately and document it. Also avoid emitting during another emit on the same type without a queue — re-entrancy creates hard-to-debug order bugs; snapshotting listeners already helps.

Further reading

Related guides