Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Build a small JavaScript event emitter with a Map of event names to listener arrays, plus on, off, once, and emit methods. This tutorial defines a compact, synchronous, Node-inspired teaching API—not a drop-in replacement for Node.js EventEmitter or the browser’s EventTarget.

What this event emitter does

An event emitter lets one part of a program register callbacks for named events and another part announce those events. The emitter stores listeners by event name; when code emits an event, it invokes the listeners and passes along any supplied arguments.

The implementation below makes its contract explicit:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Listeners run synchronously, in registration order.
  • The current dispatch uses a snapshot: adding or removing listeners during a callback affects later emissions, not the listener list already being dispatched.
  • Registering the same function more than once creates multiple registrations.
  • off removes only the first matching registration.
  • once removes its own registration before calling the original listener.
  • An unhandled error event is not treated specially; this is one important difference from Node.js.

Implement the emitter

Save this class in a JavaScript file or paste it into a browser console or Node.js project. It uses standard JavaScript features and does not depend on a particular framework.

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

  on(eventName, listener) {
    if (typeof listener !== "function") {
      throw new TypeError("listener must be a function");
    }

    const listeners = this.events.get(eventName) ?? [];
    listeners.push(listener);
    this.events.set(eventName, listeners);
    return this;
  }

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

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

  once(eventName, listener) {
    if (typeof listener !== "function") {
      throw new TypeError("listener must be a function");
    }

    const wrapped = (...args) => {
      this.off(eventName, wrapped);
      listener.apply(this, args);
    };

    return this.on(eventName, wrapped);
  }

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

    for (const listener of [...listeners]) {
      listener.apply(this, args);
    }
    return true;
  }
}

How the methods work

on: register a listener

on retrieves the array for an event name, creates one if needed, appends the callback, and returns the emitter so calls can be chained. The type check catches a common mistake early: attempting to register something that cannot be called.

emit: dispatch an event

emit looks up the listeners and calls each one immediately with the extra arguments supplied to emit. It returns true if there was at least one listener and false otherwise. The spread copy, [...listeners], establishes the snapshot policy: changes to the stored list during dispatch do not change which callbacks are in the current iteration.

For example:

const events = new EventEmitter();

events.on("message", (text, sender) => {
  console.log(`${sender}: ${text}`);
});

events.emit("message", "Build finished", "CI");
// CI: Build finished

off: remove a registration

off uses function identity to find a callback. Keep a reference to the same function when you need to remove it; a newly written arrow function with identical code is still a different function object. Because duplicates are permitted, this implementation removes one matching registration per call.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
function report(value) {
  console.log(value);
}

events.on("status", report);
events.off("status", report);

once: run a listener one time

once installs a wrapper that unregisters itself before calling the original callback. That ordering matters if the callback synchronously emits the same event again: the wrapper is already gone, so it cannot fire a second time.

let calls = 0;
events.once("ready", () => {
  calls += 1;
  events.emit("ready");
});

events.emit("ready");
console.log(calls); // 1

Check dispatch and removal behavior

These examples make the snapshot and duplicate-registration decisions observable. They are useful as small tests when adapting the emitter.

Adding a listener during dispatch

const events = new EventEmitter();
const seen = [];

function late() {
  seen.push("late");
}

events.on("tick", () => {
  seen.push("first");
  events.on("tick", late);
});
events.on("tick", () => seen.push("second"));

events.emit("tick");
console.log(seen); // ["first", "second"]

seen.length = 0;
events.emit("tick");
console.log(seen); // ["first", "second", "late"]

The newly added listener is stored immediately, but it is not in the snapshot for the first emission. It participates in the next emission.

Removing a listener during dispatch

const events = new EventEmitter();
const seen = [];

function second() {
  seen.push("second");
}

events.on("tick", () => {
  seen.push("first");
  events.off("tick", second);
});
events.on("tick", second);

events.emit("tick");
console.log(seen); // ["first", "second"]

seen.length = 0;
events.emit("tick");
console.log(seen); // ["first"]

Removing a callback changes the stored listener list but does not cancel its turn in the current snapshot. If you want removal to stop a callback that has not run yet, use a different dispatch policy and document it; that is not the behavior of this implementation.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

How this differs from Node.js and the browser

Related event APIs have different contracts. Node.js documents that its EventEmitter calls listeners synchronously in registration order. Its API also gives unhandled error events special treatment, and documents a default warning threshold of 10 listeners per event; that threshold warns about possible memory leaks but does not prevent more listeners from being added. See the Node.js Events documentation.

The implementation here follows synchronous ordered dispatch but is deliberately smaller. It does not reproduce the full Node.js API, including special unhandled-error behavior or listener warnings. The Node.js documentation surfaced for this article identifies version 22.23.1; check the documentation for the specific runtime you target if exact compatibility matters.

Browser EventTarget uses addEventListener() and dispatchEvent(), with its own event and listener rules. A listener can be a callback function or an object with a handleEvent method. MDN also notes that a listener added from inside another listener while an event is being processed does not receive that same event. Those details do not make a callback-based emitter an EventTarget implementation. See MDN’s addEventListener reference.

When to extend this implementation

  • Need Node.js compatibility? Implement the documented error-event behavior and any additional Node APIs your application relies on, then test against the target Node.js version. Do not treat this small class as a substitute for the built-in module.
  • Need browser event compatibility? Use EventTarget or implement its event-object and listener contract rather than only copying the method names.
  • Need listener diagnostics? You can add an optional warning when a per-event count becomes large. Node.js’s documented default of 10 is a warning threshold in that runtime, not a universal safe limit or a cap.
  • Need different mutation behavior? Choose whether dispatch snapshots the list or reads it live, and test additions, removals, duplicates, and nested emissions against that choice.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.