EventEmitter Implementation
Build on, off, once, and emit. Copy the listener list before emit so a handler that unsubscribes cannot skip the next listener.
- javascript
- events
- patterns
- interview
The usual EventEmitter walks this.events[name] with a for loop and an index. Listener A, on fire, calls off on itself. The array splices. Index 1 is now what used to be index 2. Listener B never runs. The interviewer asked for on, off, once, and emit. They are scoring that snapshot.
This is an in-memory pub/sub object: a Map from event name to an array of functions. It is not the DOM. It is not Node’s EventEmitter. You do not implement error as a throw-if-no-listener special case, prependListener, maxListeners, or newListener meta-events unless they ask.
Key takeaways
onpushes a function.offremoves by function reference, not by name-only unless they ask forremoveAll.emitcopies the listener list first (sliceor spread), then calls the copy.onceis a wrapper thatoffs itself, then calls the original.- Handlers run synchronously.
emitdoes notawait. A thrown listener should not skip the rest unless you say it does.- This is not Node: no
errorspecial-case, noprependListener.
on, off, once, emit, and why emit should copy the listener list
class EventEmitter {
constructor() {
this.events = new Map();
}
on(name, fn) {
if (typeof fn !== 'function') {
throw new TypeError('listener must be a function');
}
if (!this.events.has(name)) {
this.events.set(name, []);
}
this.events.get(name).push(fn);
return this;
}
off(name, fn) {
const list = this.events.get(name);
if (!list) return this;
this.events.set(
name,
list.filter((listener) => listener !== fn && listener.original !== fn)
);
return this;
}
once(name, fn) {
const wrap = (...args) => {
this.off(name, wrap);
return fn.apply(this, args);
};
wrap.original = fn;
return this.on(name, wrap);
}
emit(name, ...args) {
const list = this.events.get(name);
if (!list || list.length === 0) return false;
const snapshot = list.slice();
for (const fn of snapshot) {
fn.apply(this, args);
}
return true;
}
}
const bus = new EventEmitter();
const seen = [];
function a() {
seen.push('a');
bus.off('tick', a);
}
function b() {
seen.push('b');
}
bus.on('tick', a);
bus.on('tick', b);
bus.emit('tick');
seen; // ['a', 'b'] if emit copied; without a copy this is often ['a']
list.slice() is the whole trick. You iterate a copy. off during emit mutates the live array. The snapshot still has every listener that was registered when emit started. A listener added during emit does not run this round (it was not in the snapshot). That matches how people expect “this dispatch.” Node copies too.
If you iterate the live array by index and A removes A:
- Start
[A, B, C], index 0, call A. - A calls
off('tick', A). Live list is[B, C]. - Index becomes 1.
list[1]isC.Bis skipped.
A Set of listeners has the same bug if you iterate the Set and delete in a handler: engines skip the next entry after a delete-during-iterate. Copy first ([...set]). The pattern page at event emitter pattern uses a Set plus a copy. Here the interview shape is an array so you can draw the index skip on a whiteboard.
off by function reference
off(name, fn) compares ===. Two function expressions that look identical are different listeners. You cannot unsubscribe with a fresh arrow. Store the reference:
function onSave(doc) {}
bus.on('save', onSave);
bus.off('save', onSave);
once wraps, so off(name, original) would miss if you only compared the wrapper. Tagging wrap.original = fn lets off remove a once listener by the function the caller still holds. If you skip the tag, say that they must off the wrapper, which they do not have. Returning off from on is a nicer API and is extra:
on(name, fn) {
// ...push...
return () => this.off(name, fn);
}
off(name) with no function, meaning remove all listeners for that name, is a reasonable extra. off() with no name, meaning wipe the Map, is removeAllListeners territory. Keep it if they ask.
once
The wrapper must off before calling fn. If fn throws, you still consumed the one shot. If you off after, a throw would leave the wrapper subscribed and the next emit retries, which is not once. Same policy fork as function once.
emit is synchronous. Async listeners are fire-and-forget; their rejections are unhandled unless the listener catches. Do not make emit return a Promise.all unless they ask for emitAsync. Order is registration order.
What you are not building
Node EventEmitter |
This interview class |
|---|---|
error with zero listeners throws |
error is a string like any other |
prependListener |
push only |
maxListeners warning |
no |
this inside a listener is the emitter (unless bound) |
we apply(this, args) with this = emitter |
rawListeners, eventNames |
skip |
DOM custom events bubble on a tree. This Map does not. Use the DOM when the hierarchy is the API. Use an emitter for a store, a WebSocket client, or a test bus. Pub/sub vs observer is the vocabulary follow-on.
Thrown listeners: this sketch does not try/catch. One throw aborts the rest of the snapshot. Production often isolates with try/catch per handler so a broken subscriber cannot silence the others. Say the tradeoff. Isolating without rethrow turns bugs into swallowed errors.
Interview answer (out loud)
I keep a Map from name to an array of functions. on pushes. off filters by function reference, and I tag once-wrappers so off(original) still works. once wraps, unsubscribes itself first, then calls the original. emit slices the array and iterates the copy, so a handler that offs itself cannot skip the next listener, and a handler that ons mid-emit does not run in this dispatch. I am not implementing Node’s error special-case or prependListener.
Further reading
Related on this site
- Event emitter pattern
- Custom events
- Pub/sub vs observer
- Implement function once
- External store subscriptions
- Capture, bubble, once, and passive
Related guides
- Arrays That Emit Change EventsWrap push, pop, and splice so subscribers hear adds and removes. Proxy vs subclass, and why mutating via index assignment still slips through.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.