Back to JavaScript
JavaScript
easy
mid

How would you implement an event emitter from scratch?

A `Map<eventName, Set<listener>>` with `on`/`off`/`emit`/`once`. `on` returns an unsubscribe function. `emit` calls listeners with the payload (synchronously by default; isolate errors so one bad listener doesn't break the rest). Supports wildcard or namespacing as extensions. Foundation for pub/sub, custom stores, and decoupled module communication.

4 min read·~25 min to think through

An event emitter is publish-subscribe in ~30 lines. The interview tests not just whether you can write it, but whether you handle removal-during-emit, listener errors, and the once shape.

The core

js
class EventEmitter {
  constructor() {
    this.listeners = new Map();   // event -> Set<fn>
  }

  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);   // unsubscribe
  }

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

  emit(event, ...args) {
    const set = this.listeners.get(event);
    if (!set) return;
    for (const fn of [...set]) {        // copy! avoid mutation during iteration
      try { fn(...args); }
      catch (err) { console.error(`Listener for "${event}" threw:`, err); }
    }
  }

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

Why these specific decisions

on returns an unsubscribe function

js
const off = emitter.on("change", handler);
// later:
off();

Better than emitter.off("change", handler) — caller doesn't need to keep the function reference around.

Copy the set before iterating

A listener can call off or on during emit. Iterating the live set leads to skipped or duplicated calls. [...set] snapshots.

Catch errors per listener

Without try/catch, one listener throwing breaks the rest. Decide policy: log + continue (above), or re-throw asynchronously via queueMicrotask to surface to error tracking without breaking the loop.

once via wrapper

The wrapper detaches itself before invoking the user fn — so re-emitting from within the handler doesn't loop.

Use a Set, not an Array

  • O(1) delete vs O(n).
  • No duplicate listener registrations.

Common extensions

Wildcard / namespace

js
emit("user:created", payload);
on("user:*", handler);    // matches user:created, user:updated, ...

Implement with a separate index of wildcard listeners.

Listener count

js
listenerCount(event) { return this.listeners.get(event)?.size ?? 0; }

removeAllListeners

js
removeAllListeners(event) {
  event ? this.listeners.delete(event) : this.listeners.clear();
}

Async emit / waterfall

For async listeners, await each in sequence (emitAsync) or use Promise.all.

Backpressure / max listeners

Node's EventEmitter warns at 10 — useful for catching leaks.

Common bugs to discuss

  • Forgetting to copy the set on emit → mutation during iteration.
  • once that doesn't detach before invoking → infinite loop if handler re-emits.
  • Memory leaks — handlers held forever. Always return an unsubscribe and use it on unmount.

Use cases

  • Internal app pub/sub (cart, auth, theme changes).
  • Decoupling modules from each other.
  • WebSocket frameworks.
  • Drag-and-drop libraries.

Interview framing

"A Map<event, Set<fn>>. on adds and returns an unsubscribe function; off deletes; emit iterates over a copy of the set (handlers can subscribe/unsubscribe during emit) and isolates errors per listener so one throwing doesn't break the rest. once wraps the handler so it removes itself before invoking. Use a Set for O(1) delete and dedupe. Extensions: wildcard listeners, async emit, listener counts, removeAll. The subtle correctness wins are: copy-before-iterate, detach-before-invoke in once, and error isolation."

Follow-up questions

  • Why copy the set before iterating in emit?
  • Why detach the wrapper in once before invoking the user fn?
  • How would you implement async emit (each listener awaited)?
  • How would you add wildcard support?

Common mistakes

  • Iterating the live set during emit.
  • No error isolation — one listener kills the rest.
  • Storing listeners in an array (slow remove).
  • Once that re-runs if the handler re-emits.
  • No unsubscribe mechanism — leaks.

Performance considerations

  • O(n) emit in listeners. Set gives O(1) add/remove. For very hot events, batch or coalesce. Avoid creating closures per emit.

Edge cases

  • Subscribe during emit (should the new listener get this emit? — usually no, hence the copy).
  • Listener that throws.
  • Same fn subscribed twice (Set dedupes; Array doesn't).
  • Emit with no listeners — no-op, no error.

Real-world examples

  • Node EventEmitter, mitt, nanoevents.
  • Redux subscribe under the hood, RxJS Subject (more advanced).

Senior engineer discussion

Seniors get the copy-on-emit and once-self-detach details right, isolate errors, return unsubscribe handles to prevent leaks, and reach for an existing library (mitt is ~200 bytes) rather than rolling one when there's no special requirement.

Related questions