Event Delegation
intermediateImplement delegate(root, selector, handler) with closest() — one listener for a thousand rows, and why frameworks did exactly this.
Implement an EventEmitter in JavaScript with Map storage, re-entrancy-safe once, snapshot emits, and off-for-once — the pub/sub bugs interviewers plant.
Implement an
EventEmitter:on(event, fn),off(event, fn),once(event, fn),emit(event, ...args).
The happy path is a dictionary of arrays — ten minutes, no drama. The question earns its interview slot with four planted traps: event names that collide with Object.prototype, listeners that mutate the list during an emit, once under re-entrancy, and off after once. This is the pattern behind Node's EventEmitter, DOM EventTarget, and every store's subscribe — so the traps are real production bugs, not puzzles.
class EventEmitter {
#listeners = new Map(); // eventName → array of listener entries
on(event, listener) {
if (typeof listener !== "function") {
throw new TypeError("listener must be a function");
}
if (!this.#listeners.has(event)) {
this.#listeners.set(event, []);
}
this.#listeners.get(event).push({ listener, once: false });
return this; // chainable, like Node
}
once(event, listener) {
if (typeof listener !== "function") {
throw new TypeError("listener must be a function");
}
if (!this.#listeners.has(event)) {
this.#listeners.set(event, []);
}
this.#listeners.get(event).push({ listener, once: true });
return this;
}
off(event, listener) {
const entries = this.#listeners.get(event);
if (!entries) return this;
// match on the ORIGINAL listener — works for on() AND once() registrations
const index = entries.findIndex((e) => e.listener === listener);
if (index !== -1) entries.splice(index, 1);
if (entries.length === 0) this.#listeners.delete(event);
return this;
}
emit(event, ...args) {
const entries = this.#listeners.get(event);
if (!entries || entries.length === 0) return false;
// 1. Remove once-entries BEFORE invoking — re-entrancy safety
// 2. Iterate a SNAPSHOT — listeners may call on/off during the emit
const snapshot = [...entries];
for (const entry of snapshot) {
if (entry.once) this.off(event, entry.listener);
}
for (const entry of snapshot) {
entry.listener.apply(this, args);
}
return true; // Node convention: were there any listeners?
}
listenerCount(event) {
return this.#listeners.get(event)?.length ?? 0;
}
removeAllListeners(event) {
event === undefined ? this.#listeners.clear() : this.#listeners.delete(event);
return this;
}
}const emitter = new EventEmitter();
const log = [];
const a = (msg) => log.push(`a:${msg}`);
emitter.on("greet", a);
emitter.once("greet", (msg) => log.push(`once:${msg}`));
emitter.emit("greet", "hi"); // log: ["a:hi", "once:hi"]
emitter.emit("greet", "again"); // log: [..., "a:again"] — once is gone
emitter.off("greet", a);
emitter.emit("greet", "gone"); // returns false — no listeners left1. {} as storage — the prototype collision. With this.events = {}, the check if (!this.events[event]) consults the prototype chain: events["toString"] is the inherited method — truthy — so registration skips initialization and then calls .push on a function. on("toString", fn) throws; on("constructor", fn), on("hasOwnProperty", fn) likewise. Any user-supplied string can be an event name, so this is reachable in production. Fixes: a Map (used above — also gives you size, clean iteration, any key type) or Object.create(null). Naming the collision unprompted is the strongest opening move in this question.
2. Mutation during emit. A listener that unsubscribes (itself or others) mid-emit mutates the array being iterated — skipped or double-called listeners depending on implementation. The fix is the snapshot: iterate a copy, so the emit sees a consistent membership list — exactly the semantics DOM EventTarget guarantees ("listeners added during dispatch don't fire in this dispatch; removed ones don't fire"). One spread, deep correctness.
3. once under re-entrancy — remove before invoking. The common wrapper calls the listener then unsubscribes. If the listener (or anything it calls synchronously) emits the same event again, the wrapper is still registered → double fire, or unbounded recursion. Removing first makes once mean at most once under any control flow — the same before-vs-after policy fork as once(), showing up in its natural habitat.
4. off after once. With the wrapper approach, users hold the original function but the list holds the wrapper — off(event, original) finds nothing and the "removed" listener fires anyway. The entry-object design above sidesteps it by storing the original listener alongside a once flag; Node's actual solution is stashing the original on the wrapper (wrapper.listener = original) and having off compare both. Either works; not handling it is a silent leak of the worst kind — one the user believes they fixed.
Node streams and process events, EventTarget in every browser API, Redux's subscribe, WebSocket client reconnect logic, plugin systems. And its main failure mode is production-famous too: the listener leak — components subscribing on mount and never unsubscribing, keeping themselves (and everything they close over) alive forever. That's why Node warns past 10 listeners per event (MaxListenersExceededWarning), why React's useEffect returns a cleanup function, and why modern addEventListener accepts an AbortSignal to bulk-remove. An emitter answer that never mentions unsubscription discipline is incomplete at the senior level.
false, don't throw (but know Node's exception: an "error" event with no listener throws — deliberate crash-loudly design, great trivia with a rationale).on(e, fn) twice ⇒ fires twice; off removes one (hence findIndex+splice, not filter, which would nuke both — a subtle contract difference from the naive version).this inside listeners — we apply(this, ...) the emitter, matching Node; arrow listeners keep their lexical this regardless (binding rules).emit is fire-and-forget; returned promises are dropped. If completion matters, that's a different contract (await Promise.all(listeners.map(...)) — and now rejection policy enters). Recognizing "emitter" vs "async hook runner" as different tools is a senior distinction.emit (trap 2).once that removes after invoking (trap 3).off that can't remove once registrations (trap 4).filter-based off removing all duplicates and allocating per removal — small contract and perf differences worth being aware of, even when acceptable.user.*)." — key parsing + a second lookup pass; the design question is precedence and ordering, not code.class Emitter<Events extends Record<string, any[]>> with on<K extends keyof Events>(event: K, fn: (...args: Events[K]) => void) — the map-of-signatures pattern every typed emitter library uses.waitFor(event) returning a promise." — once + Promise.withResolvers, plus an AbortSignal for timeout — converting callback-world to promise-world (promisify's event-emitter cousin).listenerCount snapshots over time, heap snapshots keyed on retained closures, Node's warning threshold — the observability answer ties back to clearAllTimers-style registries.Implement delegate(root, selector, handler) with closest() — one listener for a thousand rows, and why frameworks did exactly this.
Model back/forward/push navigation with an index and a stack — the core of every router.
Turn real DOM into a plain-object tree — the first half of understanding how React represents UI.
Rebuild real DOM from the virtual tree, completing the render pipeline.