Implement an event emitter
An event emitter stores listeners keyed by event name and calls them when that event fires. on appends to the array for a name, off removes one by identity, and emit calls each listener with the emitted arguments and returns whether any existed.
The question
A staple, because it exercises data structures, closures and the observer pattern at once. Expect it in stages:
on,offandemit.once, which unsubscribes itself after firing.- "What happens if a listener removes itself while the event is dispatching?"
That last question is the one that separates candidates.
The solution
A Map rather than a plain object, so event names like "constructor" or
"__proto__" can’t collide with anything on the prototype.
class EventEmitter {
constructor() {
this.listeners = new Map();
}
on(eventName, listener) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, []);
}
this.listeners.get(eventName).push(listener);
return this;
}
off(eventName, listener) {
const handlers = this.listeners.get(eventName);
if (!handlers) return this;
const index = handlers.indexOf(listener);
if (index !== -1) handlers.splice(index, 1);
return this;
}
emit(eventName, ...args) {
const handlers = this.listeners.get(eventName);
if (!handlers || handlers.length === 0) return false;
// Copy first: a listener may remove itself while the event is dispatching.
for (const handler of [...handlers]) {
handler.apply(this, args);
}
return true;
}
}
How it works
A Map of name to listener array. Duplicate subscriptions are allowed and each one fires, which
is what Node does. off removes by identity, so the caller has to keep a reference
to the exact function it subscribed; this is why passing an inline arrow to on
makes a listener impossible to remove later.
emit iterates a copy of the array. Without that copy, a listener that removes
itself shifts the array underneath the loop and the next listener is silently skipped. It
returns a boolean saying whether the event had any listeners at all.
The tests it has to pass
This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 2 cases Practice Pad runs against your solution, and the implementation above passes all of them.
✓ passes emitted arguments to the listener and returns true
const emitter = new EventEmitter();
let total = 0;
emitter.on("total", (a, b) => { total = a + b; });
expectEqual(emitter.emit("total", 2, 5), true);
expectEqual(total, 7);
✓ stops calling a listener once it is removed
const emitter = new EventEmitter();
let calls = 0;
const listener = () => { calls += 1; };
emitter.on("tick", listener);
emitter.emit("tick");
emitter.off("tick", listener);
emitter.emit("tick");
expectEqual(calls, 1);
Edge cases to watch out for
Iterating the live array during emit
If a listener calls off on itself, splice shifts every later element down while the loop index moves up, so the following listener never runs. Iterating a copy costs one allocation and removes the whole class of bug.
Using a plain object for the listener store
{} inherits from Object.prototype, so an event named "toString" or "constructor" finds an inherited value instead of a listener array. A Map, or Object.create(null), avoids it.
Removing every matching listener instead of one
If the same function was subscribed twice, off should remove one subscription, not both. Using indexOf and splice(index, 1) gets this right; filter removes all of them.
Implementing once by wrapping without storing the original
A once that wraps the listener must still be removable by the original reference, so the wrapper needs to record what it wraps. Otherwise off(name, originalFn) silently does nothing.
Follow up questions
How do you implement once in an event emitter?
Wrap the listener in a function that calls off before invoking it, then subscribe the wrapper. Store a reference from the wrapper back to the original so off(name, original) can still find and remove it.
How do you stop one throwing listener breaking the rest?
Wrap each call in a try/catch inside the emit loop, and decide deliberately what to do with the error: collect them, report them, or re-throw after the loop. Node's EventEmitter doesn’t do this, so a throwing listener stops the ones after it, which is a genuinely surprising failure to debug.
Why use a Map instead of a plain object?
A plain object inherits keys from Object.prototype, so an event called toString or constructor collides with an inherited value rather than finding a listener array. A Map has no prototype chain for keys and accepts any value as a key.
Should emit call listeners synchronously?
Yes, and Node's EventEmitter does. Listeners run in subscription order, on the same tick, before emit returns. That means a throwing listener interrupts the rest, which is worth mentioning as a trade-off if asked.
Practise this with the tests running
Practice Pad runs these 2 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.
Download for macOS Free. Requires macOS 12 or later on Apple Silicon.