DevLift
Back to Blog

Build Your Own Event Emitter

Build Node.js's EventEmitter from scratch in 100 lines. Learn why listener removal requires saved references, how once() wraps callbacks, and what that MaxListenersExceededWarning actually means.

Admin
July 31, 20265 min read2 views

Build Your Own Event Emitter

Every Node.js developer has used EventEmitter. Streams use it. HTTP servers use it. The process object itself is one. But if you've never looked under the hood, there's a good chance you've hit one of its footguns without understanding why — a listener that refuses to unregister, or a memory leak warning you dismissed because you couldn't tell whether it meant anything.

Building one from scratch takes about 100 lines. Here's what you'll understand when you're done: why listener removal only works with saved references, how once() actually wraps your callback, and what that MaxListenersExceededWarning is really telling you.

What We're Building

The pub/sub pattern: producers emit named events with data, consumers register callbacks that run when those events fire.

Rendering diagram...

The core is a Map where keys are event names and values are arrays of listener functions. emit() walks the array and calls each one. Everything else — once, off, listener limits — is built on top of that.

Step 1: Core State and on()

1

Set up the listeners map and on()

// my-emitter.js
 
class MyEmitter {
  #listeners = new Map(); // eventName → fn[]
  #maxListeners = 10;
 
  on(eventName, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError(`The "listener" argument must be a Function. Received: ${typeof listener}`);
    }
 
    if (!this.#listeners.has(eventName)) {
      this.#listeners.set(eventName, []);
    }
 
    const arr = this.#listeners.get(eventName);
    arr.push(listener);
 
    // Warn if this looks like a leak — once per event, not once per listener
    if (arr.length > this.#maxListeners && this.#maxListeners !== 0 && !arr.warned) {
      arr.warned = true;
      console.warn(
        `MaxListenersExceededWarning: Possible memory leak detected. ` +
        `${arr.length} "${String(eventName)}" listeners added. ` +
        `Use setMaxListeners() to increase limit.`
      );
    }
 
    return this; // chainable
  }
 
  addListener(eventName, listener) {
    return this.on(eventName, listener);
  }
 
  setMaxListeners(n) {
    if (typeof n !== 'number' || n < 0 || !Number.isInteger(n)) {
      throw new RangeError(`The value of "n" must be a non-negative integer. Received: ${n}`);
    }
    this.#maxListeners = n;
    return this;
  }
 
  getMaxListeners() {
    return this.#maxListeners;
  }
}

A few things to note:

  • Map not a plain object. Event names can be Symbols, not just strings. Map handles both cleanly.
  • The max-listener warning fires at registration time, not at emit time. It's a heuristic — 10+ listeners for the same event almost always means a listener was added in a loop or a cleanup was missed.
  • The warned flag matters more than it looks. Without it you get a warning for the 11th listener, the 12th, the 13th, and so on — which is how a genuine signal turns into noise you learn to scroll past. Node stashes the same flag on its internal listener array.
  • Setting max to 0 disables the warning entirely (Node's behavior).
  • return this makes the API chainable: emitter.on('a', fn).on('b', fn2).

Step 2: emit()

2

Implement emit()

  emit(eventName, ...args) {
    // Special case: 'error' events with no handler throw
    if (eventName === 'error') {
      if (!this.#listeners.has('error') || this.#listeners.get('error').length === 0) {
        const err = args[0];
        if (err instanceof Error) throw err;
        const error = new Error(`Unhandled 'error' event: ${String(args[0])}`);
        error.context = args[0];
        throw error;
      }
    }
 
    if (!this.#listeners.has(eventName)) return false;
 
    const listeners = this.#listeners.get(eventName);
    if (listeners.length === 0) return false;
 
    // Snapshot the array before iterating.
    // A listener could call off() mid-loop — iterating the live array would skip entries.
    const snapshot = [...listeners];
    for (const listener of snapshot) {
      listener.apply(this, args);
    }
 
    return true;
  }

Two things deserve attention:

The snapshot. If a listener removes itself (or another listener) via off() during an emit, iterating the original array causes skipped callbacks or undefined access. Node does the same thing — its emit calls arrayClone(handler) before the loop — so the observable behaviour matches: a listener removed mid-emit still runs this time round, and won't run next time.

The error event. This is hardcoded Node.js behavior: if you emit('error', new Error('boom')) and nobody's listening, it throws. This is intentional — unhandled error events should crash loudly, not silently disappear. Add an 'error' listener before emitting errors.

Step 3: off() / removeListener()

This is where most tutorials are incomplete. Removing a listener requires matching the exact same function reference you registered with.

3

Implement off() / removeListener()

  off(eventName, listener) {
    if (!this.#listeners.has(eventName)) return this;
 
    const arr = this.#listeners.get(eventName);
    // Last occurrence: if the same fn was registered twice, remove only the most
    // recent one. Node scans backwards for exactly this reason.
    const idx = arr.lastIndexOf(listener);
 
    if (idx !== -1) {
      arr.splice(idx, 1);
    }
 
    // Clean up the map entry when the array is empty
    if (arr.length === 0) {
      this.#listeners.delete(eventName);
    }
 
    return this;
  }
 
  removeListener(eventName, listener) {
    return this.off(eventName, listener);
  }
 
  removeAllListeners(eventName) {
    if (eventName !== undefined) {
      this.#listeners.delete(eventName);
    } else {
      this.#listeners.clear();
    }
    return this;
  }

The reference trap is real:

// This works:
const handler = () => console.log('hi');
emitter.on('ping', handler);
emitter.off('ping', handler);  // removed ✓
 
// This doesn't:
emitter.on('ping', () => console.log('hi'));
emitter.off('ping', () => console.log('hi')); // different reference — not removed ✗

() => console.log('hi') creates a new function object every time. The second arrow function is not === to the first one. This is the number one cause of EventEmitter memory leaks in real apps — anonymous functions registered in event loops or component lifecycle hooks, never cleaned up.

Step 4: once()

once() is elegant. You don't need a separate internal mechanism — you just wrap the listener in a function that removes itself before calling the original.

Rendering diagram...
4

Implement once()

  once(eventName, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError(`The "listener" argument must be a Function. Received: ${typeof listener}`);
    }
 
    const wrapper = (...args) => {
      this.off(eventName, wrapper);  // Remove the wrapper first, then call
      listener.apply(this, args);
    };
 
    // Store the original on the wrapper so rawListeners() can expose it,
    // and so off(eventName, originalFn) can still work (see below)
    wrapper.listener = listener;
 
    this.on(eventName, wrapper);
    return this;
  }

The order matters: remove first, then call. If the listener itself re-registers (e.g., a once that re-adds itself after some condition), removing first ensures clean state before the callback runs.

There's a subtle problem though: if you do emitter.once('done', myFn) and then want to remove it before it fires with emitter.off('done', myFn), the off() call is looking for myFn in the array, but we stored wrapper. It won't find it.

The fix is to make off() aware of the .listener property:

  off(eventName, listener) {
    if (!this.#listeners.has(eventName)) return this;
 
    const arr = this.#listeners.get(eventName);
 
    // Match either the function itself, or a once()-wrapper that wraps it
    const idx = arr.findLastIndex(
      (fn) => fn === listener || fn.listener === listener
    );
 
    if (idx !== -1) {
      arr.splice(idx, 1);
    }
 
    if (arr.length === 0) {
      this.#listeners.delete(eventName);
    }
 
    return this;
  }

Now emitter.off('done', myFn) correctly finds and removes the wrapper even though myFn was never directly in the array.

Step 5: Introspection Methods

The missing utility methods that are surprisingly useful for debugging:

5

Add listenerCount, listeners, eventNames

  listenerCount(eventName) {
    return this.#listeners.get(eventName)?.length ?? 0;
  }
 
  listeners(eventName) {
    const arr = this.#listeners.get(eventName) ?? [];
    // Return the original functions — unwrap once() wrappers
    return arr.map((fn) => fn.listener ?? fn);
  }
 
  rawListeners(eventName) {
    // Return the raw array including once() wrappers as-is
    return [...(this.#listeners.get(eventName) ?? [])];
  }
 
  eventNames() {
    return [...this.#listeners.keys()];
  }

listeners() vs rawListeners() is a real distinction. listeners() unwraps once() wrappers to show you the original function you registered. rawListeners() returns exactly what's in the internal array, wrappers and all. Node exposes both.

Full Implementation

Everything assembled:

// my-emitter.js
 
class MyEmitter {
  #listeners = new Map();
  #maxListeners = 10;
 
  on(eventName, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError(`The "listener" argument must be a Function. Received: ${typeof listener}`);
    }
    if (!this.#listeners.has(eventName)) {
      this.#listeners.set(eventName, []);
    }
    const arr = this.#listeners.get(eventName);
    arr.push(listener);
    if (arr.length > this.#maxListeners && this.#maxListeners !== 0 && !arr.warned) {
      arr.warned = true;
      console.warn(
        `MaxListenersExceededWarning: Possible memory leak detected. ` +
        `${arr.length} "${String(eventName)}" listeners added.`
      );
    }
    return this;
  }
 
  addListener(eventName, listener) { return this.on(eventName, listener); }
 
  once(eventName, listener) {
    if (typeof listener !== 'function') {
      throw new TypeError(`The "listener" argument must be a Function. Received: ${typeof listener}`);
    }
    const wrapper = (...args) => {
      this.off(eventName, wrapper);
      listener.apply(this, args);
    };
    wrapper.listener = listener;
    return this.on(eventName, wrapper);
  }
 
  off(eventName, listener) {
    if (!this.#listeners.has(eventName)) return this;
    const arr = this.#listeners.get(eventName);
    const idx = arr.findLastIndex((fn) => fn === listener || fn.listener === listener);
    if (idx !== -1) arr.splice(idx, 1);
    if (arr.length === 0) this.#listeners.delete(eventName);
    return this;
  }
 
  removeListener(eventName, listener) { return this.off(eventName, listener); }
 
  removeAllListeners(eventName) {
    if (eventName !== undefined) this.#listeners.delete(eventName);
    else this.#listeners.clear();
    return this;
  }
 
  emit(eventName, ...args) {
    if (eventName === 'error') {
      if (!this.#listeners.has('error') || this.#listeners.get('error').length === 0) {
        const err = args[0];
        if (err instanceof Error) throw err;
        throw new Error(`Unhandled 'error' event: ${String(args[0])}`);
      }
    }
    if (!this.#listeners.has(eventName)) return false;
    const listeners = this.#listeners.get(eventName);
    if (listeners.length === 0) return false;
    for (const fn of [...listeners]) fn.apply(this, args);
    return true;
  }
 
  listenerCount(eventName) { return this.#listeners.get(eventName)?.length ?? 0; }
  listeners(eventName) { return (this.#listeners.get(eventName) ?? []).map((fn) => fn.listener ?? fn); }
  rawListeners(eventName) { return [...(this.#listeners.get(eventName) ?? [])]; }
  eventNames() { return [...this.#listeners.keys()]; }
 
  setMaxListeners(n) {
    if (typeof n !== 'number' || n < 0 || !Number.isInteger(n)) {
      throw new RangeError(`The value of "n" must be a non-negative integer. Received: ${n}`);
    }
    this.#maxListeners = n;
    return this;
  }
 
  getMaxListeners() { return this.#maxListeners; }
}
 
module.exports = { MyEmitter };

Testing It Out

6

Run the demo

// demo.js
const { MyEmitter } = require('./my-emitter');
 
const emitter = new MyEmitter();
 
// --- Basic on/emit ---
emitter.on('data', (chunk) => console.log('received:', chunk));
emitter.emit('data', 'hello');   // received: hello
emitter.emit('data', 'world');   // received: world
 
// --- once() fires exactly once ---
emitter.once('connect', () => console.log('connected!'));
emitter.emit('connect');  // connected!
emitter.emit('connect');  // (nothing)
 
// --- Removing a once() listener before it fires ---
const onDone = () => console.log('done');
emitter.once('done', onDone);
emitter.off('done', onDone);     // remove by original reference
emitter.emit('done');            // (nothing — correctly removed)
 
// --- Chaining ---
const ee = new MyEmitter()
  .on('tick', (n) => console.log('tick:', n))
  .on('tick', (n) => console.log('tock:', n));
 
ee.emit('tick', 1); // tick: 1 \n tock: 1
 
// --- listenerCount / eventNames ---
console.log(ee.listenerCount('tick'));  // 2
console.log(ee.eventNames());           // [ 'tick' ]
 
// --- The reference trap ---
ee.on('trap', () => console.log('anonymous'));
ee.off('trap', () => console.log('anonymous')); // different reference!
console.log(ee.listenerCount('trap'));  // still 1 — never removed
 
// --- Error event ---
emitter.on('error', (err) => console.log('caught:', err.message));
emitter.emit('error', new Error('network timeout')); // caught: network timeout
 
// Unhandled error throws:
const ee2 = new MyEmitter();
try {
  ee2.emit('error', new Error('boom'));
} catch (err) {
  console.log('threw:', err.message); // threw: boom
}
 
// --- Max listener warning ---
const leaky = new MyEmitter();
for (let i = 0; i < 12; i++) {
  leaky.on('ping', () => {});  // warning fires when the 11th listener is added
}

Run with node demo.js. No dependencies, no setup.

What We Skipped

This implementation covers the methods you reach for day to day, but Node's actual EventEmitter adds a few things:

  • newListener / removeListener meta-events — The real EventEmitter emits these internal events when listeners are added/removed, letting you intercept registrations. Useful for instrumentation.
  • prependListener() / prependOnceListener() — Priority listeners that go to the front of the array rather than the back. Straightforward to add (use unshift instead of push).
  • captureRejections option — When set, async listeners that return rejected promises are routed to the 'error' event instead of becoming unhandled rejections.
  • Symbol-keyed events — Our implementation works (Map handles Symbol keys), but the warning message uses String(eventName), which gives Symbol(name) — fine for debugging but worth noting.
  • The single-listener fast path — This is the one real performance difference, and it's not what you'd guess. Node's EventEmitter is plain JavaScript, no C++ hot path; go read lib/events.js and you'll recognise every line. What it does have is a shape optimisation: _events[type] holds the listener function itself when there's exactly one, and only becomes an array on the second on(). Most events in most programs have one listener, so that avoids an array allocation and an iteration per emit. Our version allocates an array for every event name, always.
  • AbortSignal integration — Modern Node.js lets you pass an AbortSignal to events.on() to automatically unregister when the signal is aborted. That's a wrapper around the core emitter, not part of it.

If you want to validate against the real thing, compare your implementation to Node's source. It's a short file, and worth reading precisely because so little of it is the data structure — most of it is the accumulated edge cases.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Build a pub/sub system from 60 lines of JavaScript: subscribe, publish, unsubscribe, once(), namespace wildcards, error isolation, and async publish.
AdminJuly 31, 20266 min read
An Observable is just a function. Build one from scratch in about 120 lines of JavaScript and finally understand why unsubscribing stops work, how operators chain, and what 'lazy' actually means.
AdminJuly 31, 20265 min read
Open a modal 10 times without cleanup and you've got 10 stacked timers silently draining memory. Here's what this looks like in a PR and how senior devs catch it.
AdminJuly 31, 20266 min read