shtabnoy.com / challenges / event-emitter
✦ Design patterns

Building an EventEmitter
from scratch

The Observer pattern — register listeners, emit events, watch callbacks fire. The same pattern behind addEventListener, Node.js streams, Socket.IO, and every pub/sub system you've ever used.

Observer patternpub/subgenericscleanupdecoupling
// try it

Register, emit, observe

These buttons call the real EventEmitter from utils/eventEmitter.ts. The callbacks, registration, and removal all happen through the actual class — not simulated.

Register listeners
Emit an event
Internal registry (Map)
No listeners registered — add some above
Event log
Register some listeners, then emit events
// the code

Implementation, typing, and real-world usage

A Map, four methods, and a generic type parameter. The same pattern powers half the libraries in your node_modules.

eventEmitter.ts
class EventEmitter {
  private _registry = new Map<string, ((...args: any) => void)[]>();

  on(event: string, callback: (...args: any) => void) {
    if (!this._registry.has(event)) {
      this._registry.set(event, []);
    }
    this._registry.get(event)!.push(callback);
  }

  off(event: string, callback: (...args: any) => void) {
    if (this._registry.has(event)) {
      const callbacks = this._registry.get(event)!;
      const index = callbacks.findIndex((c) => c === callback);
      if (index >= 0) callbacks.splice(index, 1);
    }
  }

  once(event: string, callback: (...args: any) => void) {
    const fn = (...args: any) => {
      callback(...args);
      this.off(event, fn);    // auto-remove after first call
    };
    this.on(event, fn);
  }

  emit(event: string, ...args: any) {
    this._registry.get(event)?.forEach((cb) => cb(...args));
  }
}
💡 The wrapper trick

once() wraps the original callback in a new function that calls off() on itself after firing. The wrapper has a different reference than the original, so off() removes the right function. This wrapper pattern appears everywhere — in debounce, throttle, memoize, React.memo, middleware chains.

⚠ The memory leak trap

Every on() without a matching off() is a potential memory leak. In React, this means cleaning up in useEffect return functions. In Node.js, it means calling removeListener when connections close. Node warns you at 11+ listeners on a single event — that's usually a leak.

// takeaways

What this exercise teaches

👁
The Observer pattern
Subjects (emitters) don't know who's listening. Observers (listeners) don't know who else is listening. They're decoupled through event names — the emitter just says "this happened" and anyone who cares gets notified.
🔄
It's everywhere
DOM addEventListener, Node.js EventEmitter, Socket.IO, Redux middleware, Vue's $emit, RxJS — they're all implementations of this same pattern. Learn it once, recognize it everywhere.
🧹
Cleanup is critical
Every on() should have a matching off() — otherwise listeners accumulate and cause memory leaks. React's useEffect cleanup, Angular's OnDestroy, and Vue's unmounted hooks all exist to solve this.
🎯
Generic typing is the polish
The default Record<string, any[]> makes it flexible. A custom event map makes it type-safe. Offering both via a generic default shows you understand API ergonomics, not just implementation.