Skip to content

Implement Event Emitter

advanced18 min read

Why This Question Keeps Showing Up

EventEmitter is the Swiss Army knife of decoupled communication. It powers Node.js core, every frontend framework's internal messaging, Redux middleware, WebSocket wrappers, and game engines. When a FAANG interviewer asks you to build one, they're testing whether you understand the observer pattern, can handle edge cases under pressure, and write code that won't leak memory in production.

Here's the thing most candidates miss: the basic implementation takes five minutes. What separates a hire from a no-hire is how you handle listener removal during iteration, once semantics, error propagation, and memory leak prevention. That's what we'll build.

Mental Model

Think of an EventEmitter as a bulletin board in a coffee shop. Anyone can pin a note under a topic (subscribe), and when someone announces that topic (emit), everyone who pinned a note gets notified. The twist: some people only want to hear the announcement once and then take their note down. And if too many notes pile up under one topic, the shop owner starts asking questions (memory leak warning).

The Core Interface

Before writing any code, let's nail down the API:

interface IEventEmitter {
  on(event: string, listener: Function): this;
  off(event: string, listener: Function): this;
  emit(event: string, ...args: unknown[]): boolean;
  once(event: string, listener: Function): this;
}

Every method except emit returns this for chaining. emit returns booleantrue if the event had listeners, false otherwise.

Step 1: The Foundation — on and emit

Let's start with the simplest possible version and build up:

class EventEmitter {
  constructor() {
    this._events = new Map();
  }

  on(event, listener) {
    if (!this._events.has(event)) {
      this._events.set(event, []);
    }
    this._events.get(event).push(listener);
    return this;
  }

  emit(event, ...args) {
    const listeners = this._events.get(event);
    if (!listeners || listeners.length === 0) return false;

    for (const listener of listeners) {
      listener.apply(this, args);
    }
    return true;
  }
}

Why Map instead of a plain object? Two reasons: no prototype pollution risk (someone emitting "constructor" or "__proto__" won't blow up), and Map handles any string key cleanly without hasOwnProperty checks.

Why listener.apply(this, args) instead of listener(...args)? Convention. Node.js EventEmitter sets this to the emitter instance inside listeners. It's a small detail, but interviewers notice.

Quiz
Why does the implementation use a Map instead of a plain object for storing event listeners?

Step 2: Removing Listeners — off

This is where most candidates stumble. The naive approach:

off(event, listener) {
  const listeners = this._events.get(event);
  if (!listeners) return this;

  const index = listeners.indexOf(listener);
  if (index !== -1) {
    listeners.splice(index, 1);
  }
  if (listeners.length === 0) {
    this._events.delete(event);
  }
  return this;
}

This works for the simple case. But there's a nasty bug hiding here that shows up during iteration.

The Removal-During-Iteration Trap

Consider this scenario:

const emitter = new EventEmitter();

function first() {
  console.log("first");
  emitter.off("data", second); // Remove the next listener!
}

function second() {
  console.log("second");
}

emitter.on("data", first);
emitter.on("data", second);
emitter.emit("data");

What should happen? Both first and second were registered when emit was called. A production-grade emitter should call both. But if emit iterates the original array and first splices second out mid-iteration, second gets skipped.

The fix: copy the array before iterating.

emit(event, ...args) {
  const listeners = this._events.get(event);
  if (!listeners || listeners.length === 0) return false;

  const snapshot = [...listeners];
  for (const listener of snapshot) {
    listener.apply(this, args);
  }
  return true;
}

By spreading into a new array, modifications to the original listeners array during iteration don't affect the current emit cycle. This is exactly what Node.js does internally.

Common Trap

The spread copy [...listeners] creates a shallow copy of the listener references. This is O(n) per emit. Some candidates try to optimize by using a linked list instead of an array, but for typical event systems with fewer than 100 listeners per event, the array copy is faster due to cache locality. Don't over-optimize unless you're building a high-frequency trading system.

Quiz
An EventEmitter has listeners A, B, and C for the 'data' event. During emit, listener A removes listener B. If emit iterates the original array without copying, what happens?

Step 3: Once — Fire and Forget

once registers a listener that auto-removes itself after firing. The elegant approach: wrap the original listener.

once(event, listener) {
  const wrapper = (...args) => {
    this.off(event, wrapper);
    listener.apply(this, args);
  };
  wrapper._original = listener;
  this.on(event, wrapper);
  return this;
}

Two critical details here:

1. Remove before calling. If the listener itself calls emit for the same event, we don't want it to fire again. Removing first guarantees at-most-once semantics.

2. Store the original reference. Without wrapper._original, there's no way to call off with the original listener function and have it match. We need to update off to check for this:

off(event, listener) {
  const listeners = this._events.get(event);
  if (!listeners) return this;

  const index = listeners.findIndex(
    fn => fn === listener || fn._original === listener
  );
  if (index !== -1) {
    listeners.splice(index, 1);
  }
  if (listeners.length === 0) {
    this._events.delete(event);
  }
  return this;
}

Now a user can call emitter.off("data", myHandler) and it works whether myHandler was registered with on or once.

Why remove before calling in once

Imagine a once listener that synchronously emits the same event:

emitter.once("tick", () => {
  console.log("tick!");
  emitter.emit("tick"); // re-emits the same event
});

If we call the listener before removing, the re-emit finds the wrapper still registered and fires it again — violating once semantics. By removing first, the re-emit finds no listener and skips it. This is a subtle bug that most implementations get wrong.

Step 4: Wildcard Events

Some emitters support a * wildcard that fires on every event. This is useful for logging, debugging, and middleware patterns:

emit(event, ...args) {
  let handled = false;

  const listeners = this._events.get(event);
  if (listeners && listeners.length > 0) {
    const snapshot = [...listeners];
    for (const listener of snapshot) {
      listener.apply(this, args);
    }
    handled = true;
  }

  if (event !== "*") {
    const wildcardListeners = this._events.get("*");
    if (wildcardListeners && wildcardListeners.length > 0) {
      const snapshot = [...wildcardListeners];
      for (const listener of snapshot) {
        listener.call(this, event, ...args);
      }
      handled = true;
    }
  }

  return handled;
}

Notice that wildcard listeners receive the event name as the first argument, followed by the original args. And we skip wildcard listeners when emitting "*" itself to avoid infinite recursion.

Step 5: The Error Event Convention

Node.js has a critical convention: if an "error" event is emitted with no listeners, it throws. This prevents silent failures:

emit(event, ...args) {
  const listeners = this._events.get(event);

  if (event === "error" && (!listeners || listeners.length === 0)) {
    const err = args[0];
    if (err instanceof Error) throw err;
    const fallback = new Error("Unhandled error event");
    fallback.cause = err;
    throw fallback;
  }

  if (!listeners || listeners.length === 0) return false;

  const snapshot = [...listeners];
  for (const listener of snapshot) {
    listener.apply(this, args);
  }

  if (event !== "*") {
    const wildcardListeners = this._events.get("*");
    if (wildcardListeners && wildcardListeners.length > 0) {
      const snapshot = [...wildcardListeners];
      for (const listener of snapshot) {
        listener.call(this, event, ...args);
      }
    }
  }

  return true;
}

If the first argument to emit("error", ...) is an Error, throw it directly. Otherwise, wrap it. This matches Node.js behavior exactly.

Quiz
What happens when you call emitter.emit('error', new TypeError('bad input')) and no error listeners are registered?

Step 6: Memory Leak Detection — maxListeners

In production, a common bug is attaching listeners in a loop without cleanup. Node.js warns when more than 10 listeners are added to a single event:

class EventEmitter {
  constructor() {
    this._events = new Map();
    this._maxListeners = 10;
  }

  setMaxListeners(n) {
    this._maxListeners = n;
    return this;
  }

  getMaxListeners() {
    return this._maxListeners;
  }

  on(event, listener) {
    if (!this._events.has(event)) {
      this._events.set(event, []);
    }

    const listeners = this._events.get(event);
    listeners.push(listener);

    if (this._maxListeners > 0 && listeners.length > this._maxListeners) {
      console.warn(
        `MaxListenersExceededWarning: ${listeners.length} "${event}" listeners added. ` +
        `Use emitter.setMaxListeners() to increase limit.`
      );
    }

    return this;
  }
}

The warning fires once per event when the threshold is exceeded. Setting maxListeners to 0 or Infinity disables the check. This isn't an error — it's a development-time heads-up that you might be leaking listeners.

The Complete Implementation

Here's everything put together:

class EventEmitter {
  constructor() {
    this._events = new Map();
    this._maxListeners = 10;
  }

  on(event, listener) {
    if (typeof listener !== "function") {
      throw new TypeError("Listener must be a function");
    }

    if (!this._events.has(event)) {
      this._events.set(event, []);
    }

    const listeners = this._events.get(event);
    listeners.push(listener);

    if (this._maxListeners > 0 && listeners.length > this._maxListeners) {
      console.warn(
        `MaxListenersExceededWarning: ${listeners.length} "${event}" listeners ` +
        `added to "${event}". Use emitter.setMaxListeners() to increase limit.`
      );
    }

    return this;
  }

  off(event, listener) {
    const listeners = this._events.get(event);
    if (!listeners) return this;

    const index = listeners.findIndex(
      fn => fn === listener || fn._original === listener
    );
    if (index !== -1) {
      listeners.splice(index, 1);
    }
    if (listeners.length === 0) {
      this._events.delete(event);
    }
    return this;
  }

  emit(event, ...args) {
    if (event === "error") {
      const listeners = this._events.get("error");
      if (!listeners || listeners.length === 0) {
        const err = args[0];
        if (err instanceof Error) throw err;
        const fallback = new Error("Unhandled error event");
        fallback.cause = err;
        throw fallback;
      }
    }

    let handled = false;

    const listeners = this._events.get(event);
    if (listeners && listeners.length > 0) {
      const snapshot = [...listeners];
      for (const listener of snapshot) {
        listener.apply(this, args);
      }
      handled = true;
    }

    if (event !== "*") {
      const wildcardListeners = this._events.get("*");
      if (wildcardListeners && wildcardListeners.length > 0) {
        const snapshot = [...wildcardListeners];
        for (const listener of snapshot) {
          listener.call(this, event, ...args);
        }
        handled = true;
      }
    }

    return handled;
  }

  once(event, listener) {
    const wrapper = (...args) => {
      this.off(event, wrapper);
      listener.apply(this, args);
    };
    wrapper._original = listener;
    this.on(event, wrapper);
    return this;
  }

  removeAllListeners(event) {
    if (event) {
      this._events.delete(event);
    } else {
      this._events.clear();
    }
    return this;
  }

  listenerCount(event) {
    const listeners = this._events.get(event);
    return listeners ? listeners.length : 0;
  }

  setMaxListeners(n) {
    this._maxListeners = n;
    return this;
  }

  getMaxListeners() {
    return this._maxListeners;
  }
}

TypeScript: Generic Type Safety for Event Maps

In a real codebase, you don't want emit("daat", payload) to silently do nothing because you typo'd the event name. TypeScript generics solve this:

type EventMap = Record<string, unknown[]>;

class TypedEventEmitter<T extends EventMap> {
  private events = new Map<keyof T, Function[]>();
  private maxListeners = 10;

  on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    this.events.get(event)!.push(listener);
    return this;
  }

  off<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    const listeners = this.events.get(event);
    if (!listeners) return this;

    const index = listeners.findIndex(
      (fn: any) => fn === listener || (fn as any)._original === listener
    );
    if (index !== -1) listeners.splice(index, 1);
    if (listeners.length === 0) this.events.delete(event);
    return this;
  }

  emit<K extends keyof T>(event: K, ...args: T[K]): boolean {
    const listeners = this.events.get(event);
    if (!listeners || listeners.length === 0) return false;

    const snapshot = [...listeners];
    for (const listener of snapshot) {
      listener.apply(this, args);
    }
    return true;
  }

  once<K extends keyof T>(event: K, listener: (...args: T[K]) => void): this {
    const wrapper = (...args: T[K]) => {
      this.off(event, wrapper as any);
      listener.apply(this, args);
    };
    (wrapper as any)._original = listener;
    this.on(event, wrapper as any);
    return this;
  }
}

Now usage is fully type-checked:

interface AppEvents {
  login: [user: string, timestamp: number];
  logout: [];
  error: [err: Error];
  message: [from: string, body: string];
}

const bus = new TypedEventEmitter<AppEvents>();

bus.on("login", (user, timestamp) => {
  // user: string, timestamp: number — inferred!
  console.log(`${user} logged in at ${timestamp}`);
});

bus.emit("login", "alice", Date.now()); // OK
bus.emit("login", "alice");             // TS Error: expected 2 args
bus.emit("logni", "alice", Date.now()); // TS Error: 'logni' not in AppEvents

Typos become compile-time errors. Argument types are inferred. This is the kind of type safety that prevents bugs before they ship.

Quiz
In the TypedEventEmitter, what does the generic constraint T extends Record of string to unknown array accomplish?

Edge Cases Interviewers Love

Duplicate Listeners

Should on allow the same function to be registered twice? Node.js says yes — each call adds a new entry. This is by design: the same handler might be intentionally registered for counting or idempotency patterns.

Removing All Instances

If a listener was added three times, off removes only the first match. To remove all instances, you'd need a loop or a removeAllListeners(event) method. Both are valid interview discussion points.

Emit Return Value with Wildcards

Our implementation returns true if either the specific event or wildcard had listeners. This is a design choice — document it and be ready to defend it.

Async Listeners

Our emit is synchronous. If a listener is async, the returned promise is ignored. For async support, you'd need emitAsync that collects promises and returns Promise.all(...). Mention this as a follow-up if the interviewer asks.

Quiz
A once listener calls emitter.emit for the same event inside its callback. If the once wrapper removes the listener AFTER calling the callback (instead of before), what happens?

Execution Flow: once with Re-Emit

Execution Trace
Register
once('tick', handler) adds wrapper to listeners
wrapper._original = handler
First emit
emit('tick') copies listeners to snapshot
snapshot = [wrapper]
Remove first
wrapper calls off('tick', wrapper)
Listeners array is now empty
Call handler
handler runs, calls emit('tick') internally
Re-emit finds no listeners for 'tick'
Re-emit skips
Inner emit('tick') returns false
Correct: handler fired exactly once

Performance Characteristics

OperationTime ComplexityNotes
onO(1) amortizedArray push
offO(n)Linear scan with findIndex
emitO(n)Copy array + iterate
onceO(1) for registerSame as on plus wrapper allocation
listenerCountO(1)Array length
removeAllListenersO(1)Map delete or clear

The O(n) copy in emit is the main cost. For hot paths with many listeners, consider whether you truly need the removal-during-iteration safety. If you can guarantee no listener modifies the list, skip the copy. But in an interview, always implement the safe version first.

What developers doWhat they should do
Iterating the original listeners array in emit without copying
Listeners can be removed or added during emit. Without a copy, splice shifts indices and causes listeners to be skipped or called twice.
Spread into a snapshot before iterating: const snapshot = [...listeners]
Removing the once wrapper AFTER calling the listener
If the listener re-emits the same event synchronously, the wrapper is still registered and fires again, violating once semantics.
Remove BEFORE calling: this.off(event, wrapper) then listener.apply(this, args)
Using a plain object instead of Map for the events store
Plain objects inherit from Object.prototype. Event names like constructor, __proto__, or toString collide with inherited properties and cause subtle bugs.
Use new Map() to store event-to-listeners mappings
Not storing _original reference on the once wrapper
Without this, users cannot call off() with the original function reference to cancel a once listener before it fires.
Set wrapper._original = listener so off() can match against the original function
Silently ignoring emit('error') when no listeners exist
Silent error swallowing hides bugs. The Node.js convention throws unhandled errors to force explicit handling.
Throw the error argument (or wrap in Error) when no error listeners are registered
Key Rules
  1. 1Always copy the listeners array before iterating in emit — mutations during iteration cause skipped callbacks
  2. 2In once wrappers, remove before calling — prevents double-firing on synchronous re-emit of the same event
  3. 3Use Map over plain objects for event storage — avoids prototype pollution from event names like __proto__
  4. 4Store wrapper._original so off() works with the original function reference for once-registered listeners
  5. 5Throw on unhandled error events — silent failures are worse than crashes in production
  6. 6Warn on maxListeners exceeded — it catches the most common memory leak pattern in event-driven code