Back to all articles
BackendNodejs
Build Your Own Custom Event Emitter

Build Your Own Custom Event Emitter

  • Event systems show up everywhere in JavaScript. Click handlers in the browser, HTTP servers in Node, message buses, even many UI frameworks use the same idea. You subscribe to something, later an event fires, all the subscribers run.

  • In this guide we will build that engine ourselves. We will start tiny, then add features one by one. Every snippet compiles on its own, and we will only call helpers after we have defined them, so beginners do not need to guess how things work.

What is an Event Emitter?

An event emitter is just a system where:

  • You can listen for events (on).
  • You can emit (trigger) events (emit).
  • You can remove listeners when you don’t need them anymore.

Think of it like a walkie-talkie:

One person shouts (emit) →
Everyone tuned in (on) hears it.

What we are building

An Event Emitter gives you three core abilities:

  • on(eventName, listener), register a function that should run when an event happens
  • emit(eventName, ...args), trigger an event and pass data to listeners
  • off or removeListener(eventName, listener), stop a listener from running

We will also add these quality of life extras:

  • once, run a listener only the first time the event fires
  • removeAllListeners, clean up
  • setMaxListeners and getMaxListeners, simple memory leak warnings
  • newListener and removeListener meta events, useful for debugging and tooling

We will store listeners in a Map, where the key is the event name and the value is an array of functions.

Step 1, start the class and storage

We need a place to keep listeners. A Map is perfect because lookups are fast and keys can be any string.

class CustomEventEmitter {
  constructor() {
    // eventName -> array of listener functions
    this.events = new Map();
  }
}
  • So if someone listens to "data", it might look like:
events = {
 "data" → [listener1, listener2]
}

Try it in Node with an empty file and node file.js. Nothing happens yet, that is fine.

Step 2, add a tiny emit

Before we can register listeners, it helps to know how they will be used. Let us teach the emitter how to fire an event. If there are listeners for that event, call each one with any arguments we received.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
  }

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

    // copy so changes during emit do not break the loop
    const copy = [...listeners];
    for (const fn of copy) {
      fn(...args);
    }
    return true;
  }
}
  • ...args is the rest parameter. It captures any number of values.
  • We return true if something ran, false otherwise.

Step 3, add a validator helper

We want to fail early if someone tries to register a non function. We will define a small helper now, and then call it from the next step.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
  }

  // helper, define it before we use it
  validateListener(listener) {
    if (typeof listener !== "function") {
      throw new TypeError("Listener must be a function");
    }
  }

  emit(eventName, ...args) {
    const listeners = this.events.get(eventName);
    if (!listeners || listeners.length === 0) return false;
    const copy = [...listeners];
    for (const fn of copy) fn(...args);
    return true;
  }
}

Step 4, implement on method

Now we can register listeners. If this is the first listener for an event, create an empty array first, then push.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
  }

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

  on(eventName, listener) {
    this.validateListener(listener);

    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    this.events.get(eventName).push(listener);

    return this; // lets you chain on().on()
  }

  emit(eventName, ...args) {
    const listeners = this.events.get(eventName);
    if (!listeners || listeners.length === 0) return false;
    const copy = [...listeners];
    for (const fn of copy) fn(...args);
    return true;
  }
}

Quick test

const e = new CustomEventEmitter();
e.on("greet", (name) => console.log("Hello", name));
e.emit("greet", "Alice"); // prints Hello Alice

Step 5, implement removeListener and removeAllListeners

We should be able to stop listening. We will remove one function from the array. If the array becomes empty, we will delete the key from the Map.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
  }

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

  on(eventName, listener) {
    this.validateListener(listener);
    if (!this.events.has(eventName)) this.events.set(eventName, []);
    this.events.get(eventName).push(listener);
    return this;
  }

  removeListener(eventName, listener) {
    const list = this.events.get(eventName);
    if (!list) return this;

    const idx = list.indexOf(listener);
    if (idx !== -1) {
      list.splice(idx, 1);
      if (list.length === 0) this.events.delete(eventName);
    }
    return this;
  }

  removeAllListeners(eventName) {
    if (typeof eventName === "string") {
      this.events.delete(eventName);
    } else {
      this.events.clear();
    }
    return this;
  }

  emit(eventName, ...args) {
    const listeners = this.events.get(eventName);
    if (!listeners || listeners.length === 0) return false;
    const copy = [...listeners];
    for (const fn of copy) fn(...args);
    return true;
  }
}

Quick test

const e = new CustomEventEmitter();
function handler(x) {
  console.log("value", x);
}

e.on("data", handler);
e.emit("data", 1); // value 1
e.removeListener("data", handler);
e.emit("data", 2); // nothing

Step 6, add once using a wrapper

A once listener should run the first time, then remove itself. The usual trick is to wrap the original function in a small helper that removes itself before calling the original.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
    this.onceListeners = new Map(); // optional bookkeeping
  }

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

  on(eventName, listener) {
    this.validateListener(listener);
    if (!this.events.has(eventName)) this.events.set(eventName, []);
    this.events.get(eventName).push(listener);
    return this;
  }

  once(eventName, listener) {
    this.validateListener(listener);

    const onceWrapper = (...args) => {
      this.removeListener(eventName, onceWrapper);
      listener.apply(this, args);
    };

    // keep a back reference so removal by original function still works
    onceWrapper.originalListener = listener;

    // optional bookkeeping for introspection or cleanup
    if (!this.onceListeners.has(eventName))
      this.onceListeners.set(eventName, []);
    this.onceListeners.get(eventName).push(onceWrapper);

    return this.on(eventName, onceWrapper);
  }

  removeListener(eventName, listener) {
    const list = this.events.get(eventName);
    if (list) {
      const idx = list.indexOf(listener);
      if (idx !== -1) {
        list.splice(idx, 1);
        if (list.length === 0) this.events.delete(eventName);
      }
    }

    // also check the once wrappers
    const onceList = this.onceListeners.get(eventName);
    if (onceList) {
      const idx = onceList.findIndex(
        (l) => l === listener || l.originalListener === listener
      );
      if (idx !== -1) {
        onceList.splice(idx, 1);
        if (onceList.length === 0) this.onceListeners.delete(eventName);
      }
    }
    return this;
  }

  removeAllListeners(eventName) {
    if (typeof eventName === "string") {
      this.events.delete(eventName);
      this.onceListeners.delete(eventName);
    } else {
      this.events.clear();
      this.onceListeners.clear();
    }
    return this;
  }

  emit(eventName, ...args) {
    const listeners = this.events.get(eventName);
    if (!listeners || listeners.length === 0) return false;
    const copy = [...listeners];
    for (const fn of copy) fn(...args);
    return true;
  }
}

Quick test

const e = new CustomEventEmitter();
e.once("ready", () => console.log("runs once"));
e.emit("ready"); // prints
e.emit("ready"); // nothing

Step 7, add basic safety warnings

Attaching a very large number of listeners is often a mistake. We can warn when the count goes above a limit.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
    this.onceListeners = new Map();
    this.maxListeners = 10;
    this.warningsEnabled = true;
  }

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

  checkMaxListeners(eventName, count) {
    if (
      this.warningsEnabled &&
      this.maxListeners > 0 &&
      count > this.maxListeners
    ) {
      console.warn(
        `Possible memory leak, ${count} listeners on "${eventName}". ` +
          `Use setMaxListeners() to increase the limit`
      );
    }
  }

  setMaxListeners(n) {
    if (typeof n !== "number" || n < 0) {
      throw new TypeError("Max listeners must be a non negative number");
    }
    this.maxListeners = n;
    return this;
  }

  getMaxListeners() {
    return this.maxListeners;
  }

  on(eventName, listener) {
    this.validateListener(listener);
    if (!this.events.has(eventName)) this.events.set(eventName, []);
    const list = this.events.get(eventName);
    list.push(listener);
    this.checkMaxListeners(eventName, list.length);
    return this;
  }

  // once, removeListener, removeAllListeners, emit, same as before
}

Step 8, make emit safer with error events

In Node, emitting an error without a handler should throw. We will follow that idea. If a listener throws during emit, we will re-emit an error on the next tick so your error handler can catch it.

emit(eventName, ...args) {
  const listeners = this.events.get(eventName);

  if (!listeners || listeners.length === 0) {
    if (eventName === "error") {
      const err = args[0] instanceof Error
        ? args[0]
        : new Error(args[0] || "Unhandled error event");
      throw err;
    }
    return false;
  }

  const copy = [...listeners];
  for (const fn of copy) {
    try {
      fn.apply(this, args);
    } catch (err) {
      if (eventName !== "error") {
        // schedule an error event so user code can handle it
        if (typeof process !== "undefined" && typeof process.nextTick === "function") {
          process.nextTick(() => this.emit("error", err));
        } else {
          setTimeout(() => this.emit("error", err), 0);
        }
      } else {
        throw err;
      }
    }
  }
  return true;
}

Step 9, meta events, newListener and removeListener

Tools sometimes want to know when listeners are added or removed. We can emit meta events for that. Call emit("newListener", eventName, listener) right after adding, and call emit("removeListener", eventName, listener) right after removing.

Hook them into the methods we already have:

on(eventName, listener) {
  this.validateListener(listener);
  if (!this.events.has(eventName)) this.events.set(eventName, []);
  const list = this.events.get(eventName);
  list.push(listener);

  this.checkMaxListeners(eventName, list.length);

  // meta event
  this.emit("newListener", eventName, listener);

  return this;
}

removeListener(eventName, listener) {
  const list = this.events.get(eventName);
  if (list) {
    const idx = list.indexOf(listener);
    if (idx !== -1) {
      list.splice(idx, 1);
      // meta event
      this.emit("removeListener", eventName, listener);
      if (list.length === 0) this.events.delete(eventName);
    }
  }

  const onceList = this.onceListeners.get(eventName);
  if (onceList) {
    const idx = onceList.findIndex(
      l => l === listener || l.originalListener === listener
    );
    if (idx !== -1) {
      onceList.splice(idx, 1);
      if (onceList.length === 0) this.onceListeners.delete(eventName);
    }
  }
  return this;
}

Step 10, add friendly aliases

People often expect addListener and off.

addListener(eventName, listener) {
  return this.on(eventName, listener);
}

off(eventName, listener) {
  return this.removeListener(eventName, listener);
}

Final code, ready to paste

Below is a single file version that includes every feature we built. It matches the structure you shared, with helpers defined before use inside each step of the blog and with the small safety improvement for emit when process.nextTick is not available.

class CustomEventEmitter {
  constructor() {
    this.events = new Map();
    this.onceListeners = new Map();
    this.maxListeners = 10;
    this.warningsEnabled = true;
  }

  // helpers
  validateListener(listener) {
    if (typeof listener !== "function") {
      throw new TypeError("Listener must be a function");
    }
  }

  checkMaxListeners(eventName, count) {
    if (
      this.warningsEnabled &&
      this.maxListeners > 0 &&
      count > this.maxListeners
    ) {
      console.warn(
        `Possible EventEmitter memory leak detected. ${count} ${eventName} listeners added. ` +
          `Use emitter.setMaxListeners() to increase limit`
      );
    }
  }

  setMaxListeners(n) {
    if (typeof n !== "number" || n < 0) {
      throw new TypeError("Max listeners must be a non-negative number");
    }
    this.maxListeners = n;
    return this;
  }

  getMaxListeners() {
    return this.maxListeners;
  }

  // core
  on(eventName, listener) {
    this.validateListener(listener);

    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }

    const listeners = this.events.get(eventName);
    listeners.push(listener);

    this.checkMaxListeners(eventName, listeners.length);
    this.emit("newListener", eventName, listener);

    return this;
  }

  addListener(eventName, listener) {
    return this.on(eventName, listener);
  }

  once(eventName, listener) {
    this.validateListener(listener);

    const onceWrapper = (...args) => {
      this.removeListener(eventName, onceWrapper);
      listener.apply(this, args);
    };

    onceWrapper.originalListener = listener;

    if (!this.onceListeners.has(eventName)) {
      this.onceListeners.set(eventName, []);
    }
    this.onceListeners.get(eventName).push(onceWrapper);

    return this.on(eventName, onceWrapper);
  }

  emit(eventName, ...args) {
    const listeners = this.events.get(eventName);

    if (!listeners || listeners.length === 0) {
      if (eventName === "error") {
        const error =
          args[0] instanceof Error
            ? args[0]
            : new Error(args[0] || "Unhandled error event");
        throw error;
      }
      return false;
    }

    const listenersCopy = [...listeners];

    for (const listener of listenersCopy) {
      try {
        listener.apply(this, args);
      } catch (error) {
        if (eventName !== "error") {
          if (
            typeof process !== "undefined" &&
            typeof process.nextTick === "function"
          ) {
            process.nextTick(() => this.emit("error", error));
          } else {
            setTimeout(() => this.emit("error", error), 0);
          }
        } else {
          throw error;
        }
      }
    }

    return true;
  }

  removeListener(eventName, listener) {
    const listeners = this.events.get(eventName);
    if (listeners) {
      const index = listeners.indexOf(listener);
      if (index !== -1) {
        listeners.splice(index, 1);
        if (listeners.length === 0) {
          this.events.delete(eventName);
        }
        this.emit("removeListener", eventName, listener);
      }
    }

    const onceListeners = this.onceListeners.get(eventName);
    if (onceListeners) {
      const onceIndex = onceListeners.findIndex(
        (l) => l === listener || l.originalListener === listener
      );
      if (onceIndex !== -1) {
        onceListeners.splice(onceIndex, 1);
        if (onceListeners.length === 0) {
          this.onceListeners.delete(eventName);
        }
      }
    }

    return this;
  }

  off(eventName, listener) {
    return this.removeListener(eventName, listener);
  }

  removeAllListeners(eventName) {
    if (eventName) {
      const listeners = this.events.get(eventName);
      if (listeners) {
        for (const listener of listeners) {
          this.emit("removeListener", eventName, listener);
        }
      }
      this.events.delete(eventName);
      this.onceListeners.delete(eventName);
    } else {
      for (const [event, listeners] of this.events) {
        for (const listener of listeners) {
          this.emit("removeListener", event, listener);
        }
      }
      this.events.clear();
      this.onceListeners.clear();
    }
    return this;
  }
}

export default CustomEventEmitter;

Usage demo

const emitter = new CustomEventEmitter();

emitter.on("newListener", (evt) => console.log("added listener for", evt));
emitter.on("removeListener", (evt) => console.log("removed listener for", evt));

emitter.on("greet", (name) => console.log("Hello", name));
emitter.once("ready", () => console.log("Only once"));

emitter.emit("greet", "Alice");
emitter.emit("ready");
emitter.emit("ready");

function handler(x) {
  console.log("value", x);
}
emitter.on("data", handler);
emitter.emit("data", 1);
emitter.removeListener("data", handler);
emitter.emit("data", 2);

emitter.on("error", (err) => console.log("caught:", err.message));
emitter.on("boom", () => {
  throw new Error("oops");
});
emitter.emit("boom");

Notes for running

  • If you save this as emitter.mjs, you can import it with import CustomEventEmitter from "./emitter.mjs".
  • If you prefer CommonJS, replace the export with module.exports = CustomEventEmitter and use const CustomEventEmitter = require("./emitter").

That is it. You now have a solid Event Emitter you understand line by line. If you want, to add extras like prependListener, listenerCount, or a waitFor(eventName) that returns a Promise for async workflow. you can check this gist

Have feedback?

  • Was anything confusing, hard to follow, or out of date? Let me know what you think of the article and I'll make sure to update it with your advice.
Like our article?
Sinister Spd