#Design Patterns#Polyfill

EventEmitter Polyfill

Implement a pub/sub EventEmitter class with on, off, emit, and once — the pattern behind Node's events module.

By Pratik RaiMedium

An event emitter is the publish/subscribe pattern in about forty lines: objects register callbacks against named events, and something else fires those events without knowing who is listening. It underpins Node's EventEmitter, the DOM's addEventListener, and most state management libraries. Building one is a good exercise because the naive version has a memory leak and a re-entrancy bug that are easy to demonstrate.

The interface

JSfile.javascript
1const bus = new EventEmitter(); 2 3const off = bus.on('user:login', (user) => console.log(user.name)); 4bus.once('ready', () => console.log('ready, once only')); 5 6bus.emit('user:login', { name: 'Ada' }); 7off(); // unsubscribe

Four methods carry the pattern: on to subscribe, off to unsubscribe, once for a self-removing listener, and emit to fire.

The implementation

JSfile.javascript
1class EventEmitter { 2 #listeners = new Map(); 3 4 on(event, handler) { 5 if (typeof handler !== 'function') { 6 throw new TypeError('handler must be a function'); 7 } 8 if (!this.#listeners.has(event)) { 9 this.#listeners.set(event, new Set()); 10 } 11 this.#listeners.get(event).add(handler); 12 13 // Return an unsubscribe function — far less error-prone than off(). 14 return () => this.off(event, handler); 15 } 16 17 off(event, handler) { 18 const handlers = this.#listeners.get(event); 19 if (!handlers) return this; 20 handlers.delete(handler); 21 if (handlers.size === 0) this.#listeners.delete(event); 22 return this; 23 } 24 25 once(event, handler) { 26 const wrapper = (...args) => { 27 this.off(event, wrapper); 28 handler.apply(this, args); 29 }; 30 wrapper.listener = handler; // so off(event, handler) can find it 31 return this.on(event, wrapper); 32 } 33 34 emit(event, ...args) { 35 const handlers = this.#listeners.get(event); 36 if (!handlers || handlers.size === 0) return false; 37 38 // Copy before iterating: handlers may subscribe or unsubscribe during emit. 39 for (const handler of [...handlers]) { 40 try { 41 handler.apply(this, args); 42 } catch (error) { 43 // One bad listener must not stop the others. 44 queueMicrotask(() => { throw error; }); 45 } 46 } 47 return true; 48 } 49}

Map keyed by event name, Set of handlers per event. The Set gives O(1) add and delete and naturally prevents the same function being registered twice — though note that Node's EventEmitter does allow duplicates, so this is a deliberate divergence.

The three details that matter

Copy the handler set before iterating. A listener that calls off during emit mutates the collection you are looping over. With a Set, deleting during iteration can skip the next entry. Spreading into an array first makes the emit operate on a stable snapshot — which is also what the DOM does.

Unsubscribing needs the same reference. off(event, fn) compares by identity, so an inline arrow can never be removed:

JSfile.javascript
1bus.on('tick', () => update()); // unremovable 2bus.off('tick', () => update()); // different function — does nothing

This is why on returns an unsubscribe closure. It removes the whole category of bug.

A throwing listener must not break the chain. Without the try/catch, the third listener never runs because the second threw. Rethrowing inside queueMicrotask preserves the error for global handlers and error reporting instead of swallowing it, while keeping the emit loop going.

The once wrapper problem

once registers a wrapper, not your function. So off(event, originalHandler) cannot find anything to remove — the set contains the wrapper. Storing wrapper.listener = handler lets off search by that property:

JSfile.javascript
1off(event, handler) { 2 const handlers = this.#listeners.get(event); 3 if (!handlers) return this; 4 for (const h of handlers) { 5 if (h === handler || h.listener === handler) handlers.delete(h); 6 } 7 if (handlers.size === 0) this.#listeners.delete(event); 8 return this; 9}

Node solves it the same way, and it is a detail interviewers like precisely because it only shows up when you test once plus off together.

Memory leaks

This is the pattern's defining hazard. An emitter holds a strong reference to every handler, and a handler closes over its surrounding scope. Subscribe from a component and never unsubscribe, and the component — plus everything it references — can never be collected.

Node prints a warning past ten listeners on one event for exactly this reason: it is usually a leak, not a legitimate need.

Every subscription needs a matching teardown:

JSfile.javascript
1useEffect(() => bus.on('user:login', handleLogin), []);

Returning the unsubscribe function directly works because on returns one — the effect cleanup runs it on unmount.

Deleting the event key once its set is empty matters too. Without it, an app that subscribes and unsubscribes across thousands of event names accumulates empty Set objects forever.

Common follow-ups

"Add wildcard events." Support user:* matching user:login. Requires pattern matching in emit and a decision about whether wildcard handlers fire before or after exact matches.

"Make emit async." Await each handler in sequence, or run them concurrently with Promise.all. The interesting question is what happens when one rejects.

"How does this differ from the DOM?" addEventListener adds capture/bubble phases, preventDefault, and an options object. The core registry is the same idea.

Key takeaways

  • Map of event name to Set of handlers gives O(1) subscribe and unsubscribe.
  • Snapshot the handler set before emitting so mutation during dispatch is safe.
  • Return an unsubscribe function from on — identity-based removal is too easy to get wrong.
  • once must store a back-reference so off can find the original handler.
  • Every subscription is a potential leak; always pair it with a teardown.

Goal: Implement all four EventEmitter methods. Pay attention to the .slice() in emit and the wrapper in once.

Frequently asked questions

What is an event emitter and where does the pattern show up?
An object that lets code subscribe to named events and be called when they fire — `on`, `off`, `emit`, usually with `once`. It is the publish-subscribe pattern, and it is underneath Node's `EventEmitter`, the DOM's `addEventListener`, and most state libraries' change notifications.
What data structure should hold the listeners?
A map from event name to a list of callbacks — a plain object or, better, a `Map`, since event names are arbitrary strings and a `Map` has no prototype keys to collide with. Each `on` appends, `off` removes by identity, and `emit` iterates the list for that name.
What is the subtle bug in emit?
Iterating the live array while a listener subscribes or unsubscribes during the call. Removing a listener mid-iteration shifts the indices and silently skips the next one. The fix is to iterate over a copy — `[...listeners]` — which is also what makes `once` safe to implement as a wrapper that removes itself when it fires.
What follow-ups come after a basic emitter?
Removing all listeners for an event, wildcard subscriptions, and the memory-leak question: an emitter holds a strong reference to every callback, so a component that subscribes and never unsubscribes keeps itself alive. Interviewers ask that one because it is the failure people actually ship.

Related Challenges

Continue learning with these related challenges

View All
JavaScript

Promise.all Polyfill

Implement your own Promise.all polyfill to understand how concurrent Promise handling works under the hood.

JavaScript · Promises · Async/AwaitPratik Rai ·

JavaScript

Function.prototype.call Polyfill

Implement a Function.prototype.call polyfill from scratch. Master JavaScript this binding, execution context, primitive boxing, and safe property assignment techniques.

JavaScript · Functions · thisPratik Rai ·

JavaScript

Array.prototype.reduce Polyfill

Implement Array.prototype.reduce from scratch, including the tricky no-initial-value path and the empty-array error case.

JavaScript · Arrays · PolyfillsPratik Rai ·