On this page · sections
JavaScript EventEmitter
Can you notify listeners you will never import — without skipping one of them when someone unsubscribes mid-emit?
Level: Hard · Coding Round · Design Patterns
Why This Question Matters
- Most frontend systems need decoupled events: analytics, sockets, plugins, auth → UI.
- This question checks whether you can design a small API with a real subscribe/unsubscribe lifecycle.
- Snapshot-on-emit is the production bug: mutate-while-notify skips listeners or leaks them.
- Companies ask it because the same shape is Redux, Node EventEmitter, and browser events.
Problem
“Write an EventEmitter:
on,off,emit, andonce. Listeners are identified by function reference.”
class EventEmitter {
on(event, fn) {}
off(event, fn) {}
emit(event, ...args) {}
once(event, fn) {}
}on / off
onregistersfnforevent. The same function may be registered more than once.offremoves by===. Unknown event or missingfnis a no-op.- After
once(event, fn),off(event, fn)must still work — the caller never sees your wrapper.
emit / once
emitcalls current listeners with...args. Returntrueif any ran, elsefalse.onceruns at most once, then unregisters. Unregister before callingfnso a nestedemitcannot fire it again.- If a listener
offs another listener duringemit, do not skip anyone who was already registered for this cycle.
Clarify before you code
- Event names are strings.
on/onceshould be O(1).emitis O(n).offmay scan the list.
Example
const bus = new EventEmitter();
const log = (value) => {
console.log(value);
};
bus.on("message", log);
bus.emit("message", 1);
// 1
bus.off("message", log);
bus.emit("message", 2);
// no outputonce:
bus.once("login", () => {
console.log("Logged in");
});
bus.emit("login");
// Logged in
bus.emit("login");
// no outputMental Model
Publisher
↓
event name
↓
[ fn1, fn2, fn3 ]The publisher knows the event, not the consumers.
Map {
"message" → [fn1, fn2]
"login" → [fn3]
}off needs the same function object you passed to on.
Solution
JavaScript Map
Store listeners per event:
event → listeners[]on pushes. off filters by === (and wrapper.listener for once). emit iterates a copy.
To move a once listener off the list before it runs:
this.off(event, wrapper);
fn.apply(this, args);Implementation
class EventEmitter {
constructor() {
this.events = new Map();
}
on(event, fn) {
const listeners = this.events.get(event) ?? [];
listeners.push(fn);
this.events.set(event, listeners);
return this;
}
off(event, fn) {
const listeners = this.events.get(event);
if (!listeners) {
return this;
}
const next = listeners.filter(
(listener) =>
listener !== fn &&
listener.listener !== fn
);
if (next.length === 0) {
this.events.delete(event);
} else {
this.events.set(event, next);
}
return this;
}
once(event, fn) {
const wrapper = (...args) => {
this.off(event, wrapper);
fn.apply(this, args);
};
wrapper.listener = fn;
return this.on(event, wrapper);
}
emit(event, ...args) {
const listeners = this.events.get(event);
if (!listeners?.length) {
return false;
}
for (const listener of listeners.slice()) {
listener.apply(this, args);
}
return true;
}
}Why This Works
There are three details interviewers probe.
on / off identity
This works:
const handler = () => {};
bus.on("message", handler);
bus.off("message", handler);This does not:
bus.on("message", () => {});
bus.off("message", () => {});(() => {}) === (() => {});
// falseonce wrapper
emit
↓
wrapper
↓
off(wrapper)
↓
original fnUnregister before calling fn so a nested emit cannot fire it again.
wrapper.listener = fn so off(event, fn) still finds the wrapper.
Snapshot on emit
Avoid iterating the live array. A listener may off another listener.
const first = () => bus.off("event", second);
const second = () => console.log("second");listeners.slice() is the list at emit start. Later on/off affect the next emit.
Edge Cases
Empty event
bus.emit("missing");
// falseNo throw. Order of other events unchanged.
Empty after off
Delete the Map key when the array is empty so unused event names do not leak.
Duplicate on
An array stores [handler, handler]. emit runs twice. Say this out loud — duplicate subscriptions.
Complexity
For n listeners on that event:
| Operation | Time | Space |
|---|---|---|
on() | O(1) | — |
once() | O(1) | — |
off() | O(n) | — |
emit() | O(n) | — |
| Structure | — | O(n) |
emit is O(n) because every listener must run.
Use Cases
Application events
Fan out "user:login" without the auth module importing every consumer.
Analytics
Publish "checkout:complete"; tracking subscribes and unsubscribes with the page.
WebSocket fan-out
One socket emit("message", payload) to UI listeners.
Plugins
Host emit("ready"); plugins on("ready", init).
Component lifecycle
on in mount, off in unmount — same function reference both times.
Senior Follow-Ups
1. Why snapshot listeners before emitting?
Q. Why listeners.slice() (or [...set])?
Answer: Listeners may on/off during notify. Iterating the live array can skip a listener or pick up a new one mid-cycle. The snapshot is this emit only.
“I snapshot so subscribe/unsubscribe cannot change the list this event is walking.”
2. Why does off() require function identity?
Q. Why can’t I on and off with two inline arrows?
Answer: Each () => {} is a new function object. off compares with ===. Keep a named handler and pass it to both.
“If the API removes by reference, the caller must keep the original function.”
3. How does once() work?
Q. How do you implement once(event, handler)?
Answer: Register a wrapper. It offs itself, then calls handler. Store wrapper.listener = handler so off(event, handler) still works.
“once is a wrapper that unregisters before invoke. I keep a pointer to the original fn for off.”
4. EventEmitter vs PubSub?
Q. Are they different data structures?
Answer: No. Same Map of name → listeners. Different method names and who owns the channel.
| EventEmitter | PubSub |
|---|---|
on / off / emit | subscribe / unsubscribe / publish |
| Object owns events | Shared broker / topics |
“Who owns the channel, and how coupled are publishers and subscribers? The Map is the same.”
5. What happens with duplicate subscriptions?
Q. on("x", handler) twice — one call or two?
Answer: Define the contract. An array allows [handler, handler] → fire twice. A Set stores the reference once. Do not let the data structure decide by accident.
“If duplicates are meaningful, I use an array. If once per function, I use a Set.”
6. How should listener errors be handled?
Q. One listener throws. Do the others still run?
Answer: Two contracts. Propagate: simple, remaining listeners skip. try/catch per listener: isolation, different error story. For a small EventEmitter, propagate unless they ask for a production bus.
“I would define the error contract. Small emitter: let it throw. Production bus: catch and report so one bad subscriber does not kill the rest.”
7. What would you test?
Q. What cases before you say you are done?
Answer:
✓ on + emit delivers args
✓ off by same reference
✓ off with a different arrow is a no-op
✓ emit missing event → false
✓ once fires once
✓ off(original) removes once wrapper
✓ emit snapshot: first offs second, second still runs this cycle
✓ empty event key deleted
✓ duplicate on — whatever contract you choseSame as PubSub
Do not write a second class. Wrap the emitter if they want subscribe:
function createPubSub() {
const bus = new EventEmitter();
return {
subscribe(topic, handler) {
bus.on(topic, handler);
return () => bus.off(topic, handler);
},
publish(topic, data) {
return bus.emit(topic, data);
},
};
}Returning unsubscribe from subscribe is the UI habit (unmount). Pattern background: Patterns, FP & Architecture.
Senior Engineer Interview Answer
“I’d store listeners in a Map of event → array. on pushes, off filters by identity including once wrappers, emit iterates a snapshot, once wraps then unregisters before calling.”
If they ask for PubSub:
“Same Map. subscribe/publish are on/emit. I’d return unsubscribe from subscribe and not duplicate the algorithm.”
Interview Checklist
✓ Map event → listeners
✓ on registers
✓ off by function identity
✓ once wrapper + original fn
✓ snapshot during emit
✓ duplicate behavior is intentional
✓ empty event cleanup
✓ error contract stated
✓ emit O(n), on O(1)EventEmitter = named events + identity + snapshot. PubSub is the same Map.