On this page
Event Emitterhigh-yield
Last reviewed 22 Sept 2026
Problem
Implement an EventEmitter class with on, off, once and emit:
const bus = new EventEmitter();const greet = (name) => console.log('hi', name);
bus.on('join', greet);bus.once('join', (name) => console.log('first join:', name));bus.emit('join', 'Asha'); // "hi Asha", "first join: Asha"bus.emit('join', 'Ravi'); // "hi Ravi"bus.off('join', greet);bus.emit('join', 'Mia'); // nothingClarifying questions
- Should
onreturn an unsubscribe function? Handy — many interviewers like it. - Can the same listener be added twice? Node allows it (it runs twice); agree on the behaviour.
- What does
emitreturn?trueif there were listeners, like Node. - What if a listener throws? Let it throw (Node) or catch and continue — agree first.
Approach
Store a Map from event name to an array of listeners. on pushes, off removes, emit calls each listener with the arguments. once registers a wrapper that removes itself before calling the real listener. emit must iterate over a copy of the array, because listeners may add or remove listeners while it runs.
Step-by-step build
Step 1 — on and emit
class EventEmitter { #events = new Map();
on(name, listener) { if (!this.#events.has(name)) this.#events.set(name, []); this.#events.get(name).push(listener); return this; }
emit(name, ...args) { const listeners = this.#events.get(name); if (!listeners || listeners.length === 0) return false; for (const l of [...listeners]) l.apply(this, args); // copy: safe against changes return true; }}Step 2 — off
off(name, listener) { const listeners = this.#events.get(name); if (!listeners) return this; const i = listeners.findIndex((l) => l === listener || l.original === listener); if (i !== -1) listeners.splice(i, 1); // remove one registration if (listeners.length === 0) this.#events.delete(name); return this;}Step 3 — once
The wrapper keeps a reference to the original so off(name, original) can remove a once listener too.
once(name, listener) { const wrapper = (...args) => { this.off(name, wrapper); listener.apply(this, args); }; wrapper.original = listener; return this.on(name, wrapper);}Step 4 — unsubscribe handle
subscribe(name, listener) { this.on(name, listener); return () => this.off(name, listener);}Final code
class EventEmitter { #events = new Map();
on(name, listener) { if (typeof listener !== 'function') throw new TypeError('listener must be a function'); if (!this.#events.has(name)) this.#events.set(name, []); this.#events.get(name).push(listener); return this; }
once(name, listener) { const wrapper = (...args) => { this.off(name, wrapper); listener.apply(this, args); }; wrapper.original = listener; return this.on(name, wrapper); }
off(name, listener) { const listeners = this.#events.get(name); if (!listeners) return this; const i = listeners.findIndex((l) => l === listener || l.original === listener); if (i !== -1) listeners.splice(i, 1); if (listeners.length === 0) this.#events.delete(name); return this; }
emit(name, ...args) { const listeners = this.#events.get(name); if (!listeners || listeners.length === 0) return false; for (const l of [...listeners]) l.apply(this, args); return true; }
subscribe(name, listener) { this.on(name, listener); return () => this.off(name, listener); }
removeAllListeners(name) { if (name === undefined) this.#events.clear(); else this.#events.delete(name); return this; }
listenerCount(name) { return this.#events.get(name)?.length ?? 0; }}Edge cases
- A listener that removes itself (or another) during
emitdoes not make the loop skip anyone —emititerates a copy. - A
oncelistener fires exactly once, even if the event is emitted again from inside it. offfor a listener that was never added is a no-op.- The same function added twice runs twice and needs two
offcalls, matching Node. - Emitting an event nobody listens to returns
false.
Follow-ups
- Memory leaks: listeners keep closures alive — why must components unsubscribe on unmount? Node warns above 10 listeners per event (
setMaxListeners). - Error handling: Node throws if
'error'is emitted with no listener. Add that rule. - Wildcards:
on('user:*', …)— match names with a pattern onemit. - Async emit: run listeners in microtasks, or
awaitthem in order.
Common mistakes
- Iterating the live array during
emit, so a self-removingoncelistener skips the next listener. - Removing every copy of a listener instead of one registration.
offthat cannot remove aoncelistener because only the wrapper is stored.- Arrow-function methods that break
thiswhen a method is passed around.
Related
- Concepts: closures and
thisin the JavaScript track. - Node’s built-in events module works the same way.