Back to JavaScript
JavaScript
medium
mid

How would you implement a pub sub or event emitter from scratch?

Map<event, Set<handler>>. on/off/emit, with `on` returning an unsubscribe function. Handle errors per-handler so one throw doesn't break the rest. Bonus: once, namespacing, wildcard.

6 min read·~25 min to think through

A pub-sub (event emitter / observer) is a tiny module everyone reaches for: signaling between non-parent-child components, decoupling modules, building stores. The interview test is whether you nail the API ergonomics and the error-isolation detail.

Minimal API.

ts
type Handler<T = any> = (payload: T) => void;

export class Emitter<E extends Record<string, any>> {
  private handlers = new Map<keyof E, Set<Handler>>();

  on<K extends keyof E>(event: K, handler: Handler<E[K]>): () => void {
    let set = this.handlers.get(event);
    if (!set) { set = new Set(); this.handlers.set(event, set); }
    set.add(handler as Handler);
    return () => this.off(event, handler);
  }

  off<K extends keyof E>(event: K, handler: Handler<E[K]>) {
    this.handlers.get(event)?.delete(handler as Handler);
  }

  emit<K extends keyof E>(event: K, payload: E[K]) {
    const set = this.handlers.get(event);
    if (!set) return;
    // Snapshot — handlers added/removed during emit shouldn't affect this fire.
    for (const h of [...set]) {
      try { h(payload); }
      catch (e) { console.error(`Emitter handler for "${String(event)}" threw:`, e); }
    }
  }

  once<K extends keyof E>(event: K, handler: Handler<E[K]>) {
    const off = this.on(event, (p) => { off(); handler(p); });
    return off;
  }

  clear(event?: keyof E) {
    if (event === undefined) this.handlers.clear();
    else this.handlers.delete(event);
  }
}

Usage:

ts
type Events = { "user:login": { id: string }; "user:logout": void };
const bus = new Emitter<Events>();
const off = bus.on("user:login", ({ id }) => console.log("hello", id));
bus.emit("user:login", { id: "u1" });
off();

The four design details that earn the role.

  1. on returns an unsubscribe function. Forces the caller to think about cleanup. Pairs perfectly with React's useEffect return value.
  2. Error isolation. A single try/catch per handler. Without it, one buggy listener throws, the loop aborts, and downstream handlers never run.
  3. Snapshot the set before iterating. A handler that calls bus.off (e.g., once) mutates the set during iteration. Spreading into an array before the loop avoids the bug.
  4. Generic typing on the event map. Type-safe payloads — emit("user:login", { wrongShape: 1 }) becomes a compile error. This is the modern signal.

Sync vs async emit. The version above is synchronous (handlers run on the call stack of emit). For async — schedule with queueMicrotask or setTimeout — adds a tick of latency but guarantees the caller's stack unwinds first. Node's EventEmitter is sync; the mitt library is sync; RxJS is async by composition.

Memory leak gotcha. Listeners hold references to whatever they close over. If the emitter outlives the subscriber (global bus + short-lived component), and you forget to unsubscribe, you leak. The unsubscribe-from-on API plus React's effect cleanup makes this routine.

Wildcard / namespacing. A common extension: bus.on("", handler) fires for every event; bus.on("user:", handler) fires for any user: event. Implement by checking patterns in emit. Don't ship until you need it — premature complexity.

vs Node EventEmitter / mitt. In production: use mitt (200 bytes) for the browser, Node's built-in for Node. The hand-rolled version above is mostly an interview exercise — but it teaches the right contract.

When NOT to reach for an emitter.

  • Parent → child or child → parent communication: just use props + callbacks.
  • Global app state: a Zustand/Redux store gives you the same pattern + dev tools + persistence.
  • Cross-tab messaging: BroadcastChannel.
  • Server-pushed events: WebSocket / SSE / EventSource.

The emitter shines for peer modules within a single tab that don't share a parent.

Code

tsx
const bus = new Emitter<{ "toast": { msg: string } }>();

function MyComponent() {
  useEffect(() => {
    return bus.on("toast", ({ msg }) => console.log(msg));
    // off() returned by on() becomes the cleanup
  }, []);
  return null;
}

// From anywhere:
bus.emit("toast", { msg: "Saved" });
Using the emitter in React
ts
export function createEmitter<E extends Record<string, any>>() {
  const map = new Map<keyof E, Set<Handler>>();
  return {
    on<K extends keyof E>(e: K, h: Handler<E[K]>) {
      const s = map.get(e) ?? new Set(); s.add(h as Handler); map.set(e, s);
      return () => s.delete(h as Handler);
    },
    emit<K extends keyof E>(e: K, p: E[K]) {
      const s = map.get(e); if (!s) return;
      for (const h of [...s]) { try { h(p); } catch (err) { console.error(err); } }
    },
  };
}
Tiny mitt-style functional API (no class)

Follow-up questions

  • Why snapshot the handler set before iterating?
  • How would you add wildcard support?
  • How does this differ from Node's EventEmitter?
  • When would async emit be the right choice?

Common mistakes

  • No try/catch — one throw breaks every subsequent handler.
  • No unsubscribe API → leaks when subscribers outlive their callsite.
  • Iterating the live set while handlers add/remove → mutation-during-iteration bug.
  • Memory leak via long-lived bus holding closures.

Performance considerations

  • Map<event, Set<handler>> — O(1) on/off, O(n) emit per event.
  • Avoid re-creating handlers in render loops — capture the unsubscribe in useEffect.

Edge cases

  • Handler subscribes inside emit — must not run for the current event (snapshot handles this).
  • Same handler added twice — Set dedupes; Array would fire twice.
  • Emitting an event with no listeners is a no-op (don't throw).

Real-world examples

  • mitt (used by Vue's bus pattern), Node EventEmitter, RxJS Subject, React DevTools internals.

Senior engineer discussion

Senior signal: typed event maps, unsubscribe-from-on, error isolation, snapshot iteration, and knowing when an emitter is the wrong tool.