Implement a pub-sub / 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.
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.
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:
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.
onreturns an unsubscribe function. Forces the caller to think about cleanup. Pairs perfectly with React'suseEffectreturn value.- Error isolation. A single
try/catchper handler. Without it, one buggy listener throws, the loop aborts, and downstream handlers never run. - 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. - 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
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.