JavaScript Interview Prep
Design Patterns

Event Emitter

The Production-Grade Observer

LinkedIn Hook

"Write an EventEmitter with on, off, emit, and once."

Every senior frontend and Node.js interview eventually lands on that prompt, and the reason is simple — if you can build one from scratch, you understand how Node streams, the DOM event system, Redux middleware, and most React state libraries work under the hood.

The tricky part is not the first two methods. It is the edge cases: once listeners that must remove themselves mid-iteration, the "error" event that throws if no one is listening, setMaxListeners that catches leak bugs before production, and wildcard listeners that watch every channel at once.

In this lesson you will build a complete, production-grade EventEmitter — including a wildcard variant and a reactive Store — and compare it field-by-field with Node's official API.

Read the full lesson -> [link]

#JavaScript #EventEmitter #NodeJS #DesignPatterns #InterviewPrep #SystemDesign #Frontend


Event Emitter thumbnail


What You'll Learn

  • How to implement a full EventEmitter with on, off, emit, once, and introspection methods
  • How "error" events and setMaxListeners protect you from silent failures and memory leaks
  • How to extend an emitter with wildcard listeners and build a reactive store on top of it

The Radio Tower Analogy

An Event Emitter is a full-featured implementation of the Observer pattern, designed to be reusable across your application. If the Observer pattern is the concept, the Event Emitter is the production-grade implementation. Think of it as a radio station: it broadcasts on different frequencies (events), and anyone with a radio tuned to that frequency (listener) receives the broadcast.

Complete EventEmitter Implementation

class EventEmitter {
  constructor() {
    this._events = {};
    this._maxListeners = 10; // prevent memory leaks
  }

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

    if (!this._events[event]) {
      this._events[event] = [];
    }

    // Memory leak warning
    if (this._events[event].length >= this._maxListeners) {
      console.warn(
        `Warning: ${event} has ${this._events[event].length} listeners. ` +
        `Possible memory leak. Use setMaxListeners() to increase limit.`
      );
    }

    this._events[event].push({ listener, once: false });
    return this;
  }

  // Register a one-time listener
  once(event, listener) {
    if (typeof listener !== "function") {
      throw new TypeError("Listener must be a function");
    }

    if (!this._events[event]) {
      this._events[event] = [];
    }

    this._events[event].push({ listener, once: true });
    return this;
  }

  // Remove a specific listener
  off(event, listener) {
    if (!this._events[event]) return this;

    this._events[event] = this._events[event].filter(
      (entry) => entry.listener !== listener
    );

    if (this._events[event].length === 0) {
      delete this._events[event];
    }

    return this;
  }

  // Emit an event with data
  emit(event, ...args) {
    // Handle error events specially
    if (event === "error" && !this._events["error"]) {
      const err = args[0];
      throw err instanceof Error ? err : new Error("Unhandled error event");
    }

    if (!this._events[event]) return false;

    // Copy array to handle removal during iteration (once listeners)
    const listeners = [...this._events[event]];

    listeners.forEach((entry) => {
      try {
        entry.listener.apply(this, args);
      } catch (err) {
        // Emit on "error" channel if listener throws
        if (event !== "error") {
          this.emit("error", err);
        }
      }
    });

    // Remove "once" listeners after they fire
    this._events[event] = (this._events[event] || []).filter(
      (entry) => !entry.once
    );

    if (this._events[event]?.length === 0) {
      delete this._events[event];
    }

    return true;
  }

  // Remove all listeners for an event (or all events)
  removeAllListeners(event) {
    if (event) {
      delete this._events[event];
    } else {
      this._events = {};
    }
    return this;
  }

  // Get listener count for an event
  listenerCount(event) {
    return this._events[event]?.length || 0;
  }

  // Get all event names that have listeners
  eventNames() {
    return Object.keys(this._events);
  }

  // Set max listeners (0 = unlimited)
  setMaxListeners(n) {
    this._maxListeners = n;
    return this;
  }

  // Get all listeners for an event
  listeners(event) {
    return (this._events[event] || []).map((entry) => entry.listener);
  }
}

Using the EventEmitter

const emitter = new EventEmitter();

// Regular listeners
function onMessage(sender, text) {
  console.log(`[${sender}]: ${text}`);
}

function logMessage(sender, text) {
  console.log(`LOG: Message from ${sender} at ${new Date().toISOString()}`);
}

emitter.on("message", onMessage);
emitter.on("message", logMessage);

emitter.emit("message", "Alice", "Hello!");
// [Alice]: Hello!
// LOG: Message from Alice at 2026-04-10T...

// Once listener — fires only once
emitter.once("connection", (id) => {
  console.log(`First connection: ${id}`);
});

emitter.emit("connection", "user-123"); // "First connection: user-123"
emitter.emit("connection", "user-456"); // (nothing — listener was removed)

// Remove specific listener
emitter.off("message", logMessage);
emitter.emit("message", "Bob", "Hi!");
// [Bob]: Hi!  (only onMessage fires now)

// Error handling
emitter.on("error", (err) => {
  console.error("Caught error:", err.message);
});

emitter.emit("error", new Error("Something broke"));
// "Caught error: Something broke"

Wildcard Listeners

class WildcardEmitter extends EventEmitter {
  emit(event, ...args) {
    // Emit to exact match listeners
    super.emit(event, ...args);

    // Emit to wildcard listeners
    if (this._events["*"]) {
      this._events["*"].forEach((entry) => {
        try {
          entry.listener.call(this, event, ...args);
        } catch (err) {
          console.error("Wildcard listener error:", err);
        }
      });

      // Clean up once wildcards
      this._events["*"] = this._events["*"].filter((entry) => !entry.once);
    }

    return true;
  }
}

const ee = new WildcardEmitter();

// Listen to ALL events
ee.on("*", (eventName, ...data) => {
  console.log(`[Wildcard] Event: ${eventName}`, data);
});

ee.on("login", (user) => console.log(`Welcome, ${user}!`));

ee.emit("login", "Alice");
// "Welcome, Alice!"
// "[Wildcard] Event: login" ["Alice"]

ee.emit("logout", "Alice");
// "[Wildcard] Event: logout" ["Alice"]

Practical Example: State Manager

class Store extends EventEmitter {
  constructor(initialState = {}) {
    super();
    this._state = initialState;
  }

  getState() {
    return { ...this._state };
  }

  setState(updater) {
    const prevState = { ...this._state };

    if (typeof updater === "function") {
      this._state = { ...this._state, ...updater(this._state) };
    } else {
      this._state = { ...this._state, ...updater };
    }

    // Notify listeners of changes
    this.emit("change", this._state, prevState);

    // Emit specific field changes
    for (const key of Object.keys(this._state)) {
      if (this._state[key] !== prevState[key]) {
        this.emit(`change:${key}`, this._state[key], prevState[key]);
      }
    }
  }
}

// Usage
const store = new Store({ count: 0, user: null });

store.on("change", (newState, prevState) => {
  console.log("State changed:", newState);
});

store.on("change:count", (newVal, oldVal) => {
  console.log(`Count: ${oldVal} -> ${newVal}`);
});

store.setState({ count: 1 });
// "State changed: { count: 1, user: null }"
// "Count: 0 -> 1"

store.setState((prev) => ({ count: prev.count + 1 }));
// "State changed: { count: 2, user: null }"
// "Count: 1 -> 2"

Comparison with Node.js EventEmitter API

FeatureOur ImplementationNode.js EventEmitter
on(event, fn)YesYes
once(event, fn)YesYes
off(event, fn)YesYes (alias: removeListener)
emit(event, ...args)YesYes
removeAllListeners()YesYes
listenerCount(event)YesYes
eventNames()YesYes
setMaxListeners(n)YesYes (default: 10)
prependListenerNoYes
"error" eventYes — throws if unhandledSame behavior
"newListener" eventNoYes

Event Emitter visual 1


Common Mistakes

  • Iterating the live _events[event] array while listeners remove themselves (via once) — always copy the array before iteration, otherwise the forEach skips entries.
  • Emitting "error" without ever registering an error listener — unhandled error events must throw, and the safety is only useful if you actually attach a handler somewhere.
  • Adding listeners in a loop (e.g., one per rendered row) without ever removing them — the setMaxListeners warning exists exactly for this case; heed it, don't mute it.

Interview Questions

Q: Implement an EventEmitter class with on, off, emit, and once methods.

(See the full implementation above.) Key points: store listeners in an object keyed by event name, on pushes to the array, off filters out the listener, emit iterates and calls each listener, once wraps the listener so it removes itself after first invocation.

Q: How does Node.js handle "error" events on EventEmitter?

If an "error" event is emitted and no listener is registered for it, Node.js throws the error as an unhandled exception. This is a safety mechanism — it forces developers to handle errors explicitly rather than silently swallowing them.

Q: What is the purpose of setMaxListeners?

It sets a warning threshold for the number of listeners on a single event. The default is 10. If more listeners are added, a warning is printed to help detect memory leaks (e.g., attaching listeners in a loop without removing them). Setting it to 0 disables the warning.


Quick Reference — Cheat Sheet

EVENT EMITTER — QUICK MAP

Core API:
  on(event, fn)        -> register listener
  off(event, fn)       -> remove listener
  emit(event, ...args) -> fire all listeners
  once(event, fn)      -> auto-remove after first fire

Introspection:
  listenerCount(event), eventNames(), listeners(event)

Safety:
  "error" event throws if no listener registered
  setMaxListeners(n) -> warn on likely leaks (default 10)
  copy array before iterating so once-removals don't skip

Extensions:
  wildcard "*" listener -> fires on every event
  subclass + setState -> reactive store ("change" + "change:field")

Previous: Factory Pattern -> Build by Description Next: Dependency Injection -> Push, Don't Pull


This is Lesson 12.5 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page