ESC

Type to search the knowledge base.

EventEmitter Implementation

Build on, off, once, and emit. Copy the listener list before emit so a handler that unsubscribes cannot skip the next listener.

intermediate5 min read
  • 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

  • on pushes a function. off removes by function reference, not by name-only unless they ask for removeAll.
  • emit copies the listener list first (slice or spread), then calls the copy.
  • once is a wrapper that offs itself, then calls the original.
  • Handlers run synchronously. emit does not await. A thrown listener should not skip the rest unless you say it does.
  • This is not Node: no error special-case, no prependListener.

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:

  1. Start [A, B, C], index 0, call A.
  2. A calls off('tick', A). Live list is [B, C].
  3. Index becomes 1. list[1] is C. B is 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 guides