Problem and Applicable Context
Implement an EventEmitter class with four methods: on(eventName, listener) registers a persistent listener, once(eventName, listener) registers a one-time listener, off(eventName, listener) removes one matching registration, and emit(eventName, ...args) dispatches an event synchronously. An event name may be a string or a Symbol. The same function may be registered more than once for one event.
This question uses an explicit subset of Node-style semantics. Listeners run synchronously in registration order. this inside an ordinary listener function is the emitter. emit returns true when the event has listeners and false otherwise, and listener return values are ignored. A once registration must be removed before its listener is called so a reentrant emit cannot invoke it again.
The listeners for one dispatch are fixed when that dispatch begins. If one listener removes another during dispatch, the removed listener still participates in the dispatch already in progress but not in later ones. A newly added listener waits until a later dispatch. The implementation does not reproduce Node.js's special error event, listener-count warnings, prependListener, async iteration, or Promise rejection capture; those belong to a broader compatibility assignment.
What the Interviewer Is Evaluating
A strong answer defines the event contract before choosing a container. A Map<eventName, entries[]> locates the array for an event, and the array preserves registration order. Each entry stores the original function, whether it is a once registration, whether it has fired, and whether it remains active. A Set makes removal convenient but silently coalesces duplicate functions, violating the stated duplicate-registration rule.
The second signal is distinguishing the live array from the dispatch snapshot. Iterating the live array directly allows an earlier listener's off call to shift indexes and skip the next item. An on call may also append a listener that unexpectedly joins the current dispatch. Copying the active entries first fixes the mutation boundary at the next emit.
The third signal is reentrant once behavior. Removing a one-time listener after its callback returns is too late: the callback can emit the same event while its registration is still present. The entry should be marked inactive and fired before invocation. The fired flag also prevents an outer snapshot from invoking the entry after a nested dispatch has already done so.
Complexity should be stated accurately. If an event has k listeners, the array append in on and once is amortized O(1). off scans backward and removes an array element, so it is O(k). Snapshot creation, invocation, and cleanup make emit O(k), plus the listeners' own running time. Event lookup is commonly treated as average O(1) for Map implementations, but ECMAScript requires only average access better than linear. A Map does not make all four operations unconditionally O(1).
Questions to Clarify Before Answering
- Which methods and return values are required? With only
on,off, andemit, there is no one-time state to retain. Addingoncerequires an exact removal point. Returning listener results fromemitwould change the data flow; this contract returns only whether listeners existed. - May the same function be registered more than once, and does
offremove one or all registrations? This contract allows duplicates and removes the most recent matching registration. A deduplicating Set would implement a different contract. - When do additions and removals during dispatch take effect? This contract uses snapshot semantics: listeners present at the start join the current dispatch, while additions and removals affect later dispatches. A contract requiring immediate removal would need an active-state check before every call.
- Are listeners synchronous, and what happens when one throws? Listeners run synchronously in order. A thrown error propagates from
emit, so later listeners do not run. Cleanup of inactive entries must still happen infinally. - Is full Node.js compatibility required? If it is, special
errorbehavior, meta-events, listener limits, and more APIs enter scope. This implementation covers only the declared core subset; sharing a class name does not establish full compatibility.
These answers change the storage model, loop conditions, return value, and error boundary, so settle them before coding.
30-Second Answer Framework
“I will store an entry array per event in a Map, dispatch synchronously in registration order, and allow duplicates. off removes the newest match; dispatch mutations take effect next time. emit snapshots active entries. Before calling once, it marks the entry inactive and fired to block reentrancy, then cleans in finally. Under average Map lookup, registration is amortized O(1), while off and emit are O(k).”
Step-by-Step Deep Dive
The smallest implementation maps each event to an array of functions and calls them in order. It passes a sample that registers two functions and emits once, but leaves four result-changing questions unanswered: how duplicate functions are removed, what happens when the array changes during dispatch, when a one-time listener is removed, and how state is cleaned after a listener throws.
The recommended implementation separates a registration record from its function. Two registrations of the same function remain distinct entries, and once does not need to hide the original function behind a wrapper:
class EventEmitter {
constructor() {
this.events = new Map();
}
on(eventName, listener) {
return this._add(eventName, listener, false);
}
once(eventName, listener) {
return this._add(eventName, listener, true);
}
off(eventName, listener) {
const entries = this.events.get(eventName);
if (!entries) return this;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.active && entry.listener === listener) {
entry.active = false;
entries.splice(index, 1);
break;
}
}
if (entries.length === 0) {
this.events.delete(eventName);
}
return this;
}
emit(eventName, ...args) {
const entries = this.events.get(eventName);
if (!entries) return false;
const snapshot = entries.filter((entry) => entry.active);
if (snapshot.length === 0) return false;
try {
for (const entry of snapshot) {
if (entry.once) {
if (entry.fired) continue;
entry.fired = true;
entry.active = false;
}
entry.listener.apply(this, args);
}
} finally {
const currentEntries = this.events.get(eventName);
if (currentEntries) {
const activeEntries = currentEntries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
this.events.delete(eventName);
} else if (activeEntries.length !== currentEntries.length) {
this.events.set(eventName, activeEntries);
}
}
}
return true;
}
_add(eventName, listener, once) {
if (typeof listener !== "function") {
throw new TypeError("listener must be a function");
}
const entries = this.events.get(eventName);
const entry = { listener, once, fired: false, active: true };
if (entries) {
entries.push(entry);
} else {
this.events.set(eventName, [entry]);
}
return this;
}
}The snapshot fixes membership and order for the current dispatch, but holds references to the entries. A normal entry removed by off during dispatch is still invoked from the current snapshot; it disappears when the next snapshot is built. A new entry is appended only to the live array, so it cannot enter the old snapshot.
A one-time entry needs two state fields. active = false hides it from a nested emit. fired = true handles a less obvious reentrant order: a nested dispatch may invoke the entry first while the outer snapshot still holds the same reference. The outer loop later sees fired and skips the second invocation. Both marks happen before the listener call, so a thrown error cannot revive the one-time registration.
The finally block compacts inactive entries. Without it, a listener that throws could leave a logically removed once entry in the live array. The exception still propagates to the caller of emit; finally preserves the internal invariant without swallowing the failure.
At minimum, test these boundaries: no listeners returns false; arguments and this are forwarded; duplicate registrations produce duplicate calls and off removes only the newest one; repeated dispatch calls a once listener once; removing a later listener during dispatch does not remove it from that dispatch; an added listener waits for the next dispatch; nested dispatch cannot call a once entry twice; and a thrown listener still leaves the one-time entry removed.
High-Quality Sample Answer
“I will scope this to a Node-style core contract rather than full compatibility. Event names may be strings or Symbols. Listeners run synchronously in registration order, duplicates are allowed, and off removes the most recent matching registration. Dispatch uses a snapshot, so additions and removals during one dispatch affect later dispatches. emit reports whether it found listeners, and listener exceptions propagate to its caller.
I will store an entry array per event in a Map. The array preserves order, while each entry retains the original function and one-time state. emit first filters the current snapshot. Before invoking a one-time entry, it marks it inactive and fired: inactive prevents a nested dispatch from selecting it, and fired prevents an outer snapshot from invoking it after a nested dispatch already has. A try...finally around the loop guarantees cleanup after a listener throws without catching the exception.
This model preserves duplicate registrations and snapshot semantics. Under the conventional average Map lookup assumption, appending in on and once is amortized O(1). off scans and shifts an array, and emit copies, invokes, and cleans entries, so both are O(k). If off had to be O(1), I would use linked-list entries plus an index, accepting more code and memory; the current constraints do not justify that cost.”
Common Mistakes
- Storing listeners in a
Set→ registering the same function twice leaves one item and changes duplicate semantics → use an entry array so every registration remains distinct. - Iterating the live array directly →
offshifts indexes andonmay pull a new item into the current dispatch → take a snapshot of active entries when dispatch starts. - Removing
onceafter the callback returns → a callback can reenter the event before removal, and a thrown error may prevent removal entirely → setactive = falseandfired = truebefore invocation. - Recording inactive state without fired state → after a nested dispatch invokes once, an older outer snapshot may invoke the same entry again → store a fired guard on the shared entry.
- Using
filterinoffto remove every matching function → one call erases all duplicate registrations → scan backward and remove only the newest active entry. - Catching a listener error and continuing → the caller loses the failure and the error boundary changes silently → clean state in
finallyand let the exception propagate. - Claiming every operation is
O(1)→ Map does not promise strict constant lookup, and array search, removal, snapshot creation, and traversal depend on listener count → state the Map assumption, then give amortizedon/once O(1)andoff/emit O(k)separately.
Follow-Up Questions and Responses
Follow-up 1: Why should a listener removed during dispatch still finish that dispatch?
A snapshot fixes the participants when emit begins and prevents an earlier listener from changing later indexes. The implementation holds entry references in the snapshot but does not recheck active before calling a normal listener, so it still runs in the current dispatch. The next snapshot excludes it. A product may choose immediate removal by checking active before every call, but that is a different contract and needs different tests.
Follow-up 2: How can you prove that once runs at most once under reentrancy?
A one-time entry can enter its call path only while fired is false. The first dispatch to reach it sets fired to true before calling the listener. Nested and outer snapshots hold the same entry reference, so every later attempt observes true and skips it. This establishes at-most-once behavior regardless of nesting order. Test the hardest order by placing a normal listener first that emits recursively only on its first call, followed by a once listener.
Follow-up 3: What changes if off must be O(1)?
An array cannot provide both arbitrary-entry removal in O(1) and stable order. Maintain a doubly linked list per event and a Map<listener, nodes[]> that locates the most recently registered node for a function. Unlinking that node is O(1), while emit remains O(k). The cost is two pointers per registration, duplicate-function index maintenance, and more complicated snapshot behavior. For small listener sets with infrequent removal, the array is easier to verify.
Follow-up 4: What if a listener returns a Promise or throws?
The current emit is synchronous and ignores return values. A synchronous throw stops the dispatch and propagates to the caller; finally only cleans state. A returned Promise is not awaited. If callers need completion, define a separate emitAsync contract and choose serial or parallel execution plus fail-fast or all-results error handling. Adding await to the existing loop without those decisions leaves the API ambiguous.
Follow-up 5: How would you verify that a listener added during dispatch waits until the next dispatch?
Register A and have A register B when called. The first emit snapshot contains only A, so only A appears. The second snapshot contains A and B, producing A then B. Also have A remove C after C has entered the first snapshot: the first dispatch should still produce A then C, while the second excludes C. Together, those assertions cover both sides of the mutation boundary.