Event Emitter and Observer
Key Learning Objectives
Differentiate the Observer pattern from the Publish-Subscribe pattern in frontend architectures.
Implement production EventEmitter methods: on, once, off, and emit with argument forwarding.
Master the snapshot array pattern (slice) to prevent index-skipping bugs when listeners unsubscribe during emit.
Understand synchronous execution guarantees of event emitters compared to browser event dispatch.
Prevent memory leaks by managing listener unregistration lifecycles in single-page applications.
The Interview Problem
What is logged to the console when multiple listeners are registered, removed dynamically during dispatch, and fired twice across on() and once() subscriptions?
1class EventEmitter {2 constructor() {3 this.events = new Map();4 }5 on(event, listener) {6 if (!this.events.has(event)) {7 this.events.set(event, []);8 }9 this.events.get(event).push(listener);10 return this;11 }12 once(event, listener) {13 const wrapper = (...args) => {14 this.off(event, wrapper);15 listener.apply(this, args);16 };17 return this.on(event, wrapper);18 }19 off(event, listener) {20 const listeners = this.events.get(event);21 if (!listeners) return this;22 const index = listeners.indexOf(listener);23 if (index !== -1) listeners.splice(index, 1);24 return this;25 }26 emit(event, ...args) {27 const listeners = this.events.get(event);28 if (!listeners || listeners.length === 0) return false;29 const copy = listeners.slice();30 for (const listener of copy) {31 listener.apply(this, args);32 }33 return true;34 }35}3637const emitter = new EventEmitter();38const log = [];3940const fnA = () => log.push('A');41const fnB = () => {42 log.push('B');43 emitter.off('test', fnA);44};45const fnC = () => log.push('C');4647emitter.on('test', fnA);48emitter.on('test', fnB);49emitter.on('test', fnC);50emitter.once('test', () => log.push('ONCE'));5152emitter.emit('test');53emitter.emit('test');5455console.log(log.join('-'));
Predict Console Output
Select the option that matches what standard ECMAScript prints to the console:
A-B-C-ONCE-B-C
A-B-ONCE-B-C
A-B-C-ONCE-A-B-C
A-B-C-ONCE-B-C-ONCE
V8 Engine Execution Trace
Step 1 of 7 (Line 48)Registers listeners fnA, fnB, fnC with on(), and wrapper with once().
Deep Technical Breakdown
Observer Pattern vs Publish-Subscribe Pattern
Although frequently conflated in frontend interviews, they represent different architectural topologies:
- Observer Pattern: The Subject maintains direct references to its Observers and calls their update methods directly.
- Publish-Subscribe Pattern: Publishers and Subscribers do not know about each other. Communication is mediated entirely through an intermediary Event Bus or Channel (such as
EventEmitter).
The Classic Interview Trap: Array Mutation During Dispatch
Consider what happens if emit() is written naively without a snapshot copy:
// BUGGY IMPLEMENTATION
emit(event, ...args) {
const listeners = this.events.get(event);
for (let i = 0; i < listeners.length; i++) {
listeners[i](...args);
}
}If listeners[0] calls emitter.off(event, listeners[0]), listeners.splice(0, 1) shifts every element left by 1 index! On the next loop iteration, i becomes 1, which accesses what was previously listeners[2]. The listener at index 1 is completely skipped!
The Solution: Always clone the listener array prior to iteration using const copy = listeners.slice().
Synchronous Dispatch Guarantee
Node.js and browser EventEmitter implementations execute listener callbacks synchronously in the exact order they were registered. Exceptions thrown inside a listener will halt execution of subsequent listeners unless caught defensively with try/catch.
Memory Leak Prevention
Every .on() listener retains references to its parent scope. In modern React or Single Page Applications, failing to call .off() when components unmount causes detached closures and DOM nodes to persist forever.
Common Traps & Mistakes
Iterating over the listener array without creating a shallow copy (slice), causing listeners to be skipped when a callback removes a subscription.
Assuming `emit()` invokes callbacks asynchronously. EventEmitter callbacks run synchronously on the active call stack.
Failing to clean up `once()` wrappers if the event never fires, accumulating dangling functions in memory.
Not handling arguments or `this` context binding inside the listener invocations.
FAANG Follow-Up Probes
Probe #1
How would you implement an unsubscribe callback returned directly from emitter.on(event, fn)?
Probe #2
How does Node.js EventEmitter handle error events when no error listener is registered? (Throws uncaught exception).
Probe #3
How would you add wildcard event listening (e.g. emitter.on('user:*', handler)) to an EventEmitter?
