Implement an event emitter
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.
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
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
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
emit("user:created", payload);
on("user:*", handler); // matches user:created, user:updated, ...Implement with a separate index of wildcard listeners.
Listener count
listenerCount(event) { return this.listeners.get(event)?.size ?? 0; }removeAllListeners
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.
oncethat 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).