completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Build an event emitter

Build a production-quality JavaScript EventEmitter with on, off, once, emit, error handling, cleanup, and Observable adapters.

By the end, you can
  • 01
    Store and remove listenersKeep listener functions by event name, return a cleanup function from on, and count or remove listeners.
  • 02
    Handle the tricky casesWrite once and off, copy the list before emit calls anyone, and follow Node's rules for errors.
  • 03
    Connect to real toolsUse the emitter with Observables and AbortSignal, and compare it with Node's EventEmitter and the browser's EventTarget.

What you will build

An event is a message that says something happened, like "the cart changed". A listener is a function that waits for one kind of event. An event emitter is an object that keeps listeners and calls them when their event happens. In this lesson you build a real one, step by step, and test its tricky cases.

The observer & pub/sub lesson explains why this is useful: one part of an app announces a change, and other parts react without knowing about each other. The functors & monads lesson built a tiny Observable. Here you write the emitter itself.

Plain definition

An event emitter keeps a list of listener functions for each event name. When you call emit(name, ...args), it calls every listener in that name's list, in the order they were added, and passes them the extra values. It does this right away, before the next line of your code runs.

Real-life analogyA front desk message board

A hotel front desk has one tray for each topic, like "deliveries" or "taxi". Staff write their name on the trays they care about. When a message lands in a tray, the desk calls everyone whose name is on that tray, in the order they signed up.

In real life: A message lands in the tray for one topic
In JavaScript: emit(event, ...args) sends one named event
In real life: Staff write their name on a topic's tray
In JavaScript: on(event, listener) adds a function to that event's list
In real life: A one-time name is crossed off before the message is handed over
In JavaScript: once(event, listener) removes itself, then calls your function
In real life: Staff cross their own name off
In JavaScript: The function that on returns, or off, removes a listener

The code uses ideas from other lessons. You don't need to reread them, but they help:

  • Closures: a function remembers the variables around it. The cleanup functions use this.
  • Events and custom events: the browser's own event system, which we compare with ours near the end.
  • Cancellation: AbortSignal, a way to stop many things at once.
  • Memory management: why listeners you forget to remove waste memory.
  • Classic utilities: shows once as a helper on its own. Here once is a method of the emitter.

The table below lists what our emitter must do. Read each row as: "for this job, this is the rule our emitter follows." Each later section shows one row in code.

What our emitter must do
The jobThe rule our emitter follows
Add a listeneron(event, listener) adds the function to the end of that event's array and returns a cleanup function that removes it.
Send an eventemit(event, ...args) calls the listeners right away, in the order they were added. It returns true if at least one ran.
The same function added twiceoff(event, fn) removes only the copy added most recently, like Node.
Run a listener one timeonce stores a wrapper that keeps your function on .listener. The wrapper removes itself before it calls your function.
Change listeners during an emitemit copies the array first, so added or removed listeners only matter on the next emit.
An error event that nobody hearsSending error with no error listener throws the error, like Node.

Store listeners by event name

REAL CLASS

The emitter keeps its listeners in a Map. A Map is a lookup table: you give it a key, and it gives back a value. Here the key is an event name, like "save". The value is an array of the listener functions for that name.

Why an array, and not a Set? A Set keeps each value only once. An array lets you add the same function twice, and it keeps the order in which you added them. Both matter, as you'll see in the next section.

Node.js has its own built-in EventEmitter. Its on method returns the emitter itself, so you can chain calls like bus.on(...).on(...). Our on is different on purpose. It returns a cleanup function: a function that removes the listener when you call it. Web pages need to remove listeners often, so this makes cleanup easy. Other methods, like off, removeAllListeners, and setMaxListeners, still return the emitter, so you can chain them.

Here is the whole class. It is long, so the list after it walks through it in parts. Names that start with # are private: only code inside the class can use them.

The lesson's full EventEmitter classPop out in the code editor (opens in a new tab)JavaScript
class EventEmitter {  #events = new Map();  #maxListeners = 10;  #cleanup = new WeakMap();  #warned = new Set();   on(event, listener, options = {}) {    const stored = (...args) => listener(...args);    stored.listener = listener;    return this.#subscribe(event, stored, options);  }   once(event, listener, options = {}) {    const wrapper = (...args) => {      this.#removeStored(event, wrapper, true);      listener(...args);    };    wrapper.listener = listener;    return this.#subscribe(event, wrapper, options);  }   off(event, listener) {    this.#removeStored(event, listener, false);    return this;  }   emit(event, ...args) {    const listeners = this.#events.get(event);    if ((!listeners || listeners.length === 0) && event === "error") {      const error = args[0];      throw error instanceof Error ? error : new Error(        "Unhandled error event: " + String(error),      );    }    if (!listeners || listeners.length === 0) return false;    const snapshot = [...listeners];    for (const listener of snapshot) {      listener(...args);    }    return true;  }   listenerCount(event) {    return this.#events.get(event)?.length ?? 0;  }   removeAllListeners(event) {    const events = event === undefined ? [...this.#events.keys()] : [event];    for (const name of events) {      const listeners = this.#events.get(name) ?? [];      for (const listener of listeners) this.#cleanup.get(listener)?.();      this.#events.delete(name);      this.#warned.delete(name);    }    return this;  }   setMaxListeners(count) {    if (!Number.isInteger(count) || count < 0) {      throw new RangeError("count must be a non-negative integer");    }    this.#maxListeners = count;    return this;  }   #subscribe(event, stored, options) {    if (options.signal?.aborted) return () => false;    let removed = false;    const unsubscribe = () => {      if (removed) return false;      removed = this.#removeStored(event, stored, true);      return removed;    };    if (options.signal) {      const abort = () => unsubscribe();      this.#cleanup.set(stored, () => options.signal?.removeEventListener("abort", abort));      options.signal.addEventListener("abort", abort, { once: true });    }    const listeners = this.#events.get(event) ?? [];    listeners.push(stored);    this.#events.set(event, listeners);    if (this.#maxListeners > 0 && listeners.length > this.#maxListeners && !this.#warned.has(event)) {      this.#warned.add(event);      console.warn("Possible EventEmitter memory leak detected. " + listeners.length + " " + event + " listeners added.");    }    return unsubscribe;  }   #removeStored(event, listener, exact) {    const listeners = this.#events.get(event);    if (!listeners) return false;    for (let index = listeners.length - 1; index >= 0; index -= 1) {      const candidate = listeners[index];      if (candidate === listener || (!exact && candidate.listener === listener)) {        listeners.splice(index, 1);        this.#cleanup.get(candidate)?.();        this.#cleanup.delete(candidate);        if (listeners.length === 0) this.#events.delete(event);        return true;      }    }    return false;  }}

Read the class in parts:

  • Lines 2 to 5 hold private data. #events is the Map of listener arrays.
  • Lines 7 to 11 are on. It wraps your function in a new one, then hands it to #subscribe, which adds it to the list and returns the cleanup function.
  • Lines 13 to 20 are once, and lines 22 to 25 are off. The next section explains both.
  • Lines 27 to 41 are emit. It copies the list, then calls each listener in the copy.
  • Lines 66 to 87 are #subscribe. It adds the listener, builds the cleanup function, and handles the signal option you'll meet later.
  • Lines 89 to 103 are #removeStored. It finds one listener and takes it out of the list.
Add one listener and send one eventPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.on("tea:ready", (drink) => console.log("ready " + drink));console.log(bus.emit("tea:ready", "tea"));

Line 2 adds a listener for the event name tea:ready. Line 3 sends that event with the value "tea". The listener gets "tea" as drink and prints ready tea. Then emit returns true, because a listener ran, and line 3 prints it.

The event name tea:ready points to a box of listeners. The box holds one function, (drink) =>. Calling it with tea prints ready tea.tea:readyevent name(drink) =>listenersready teaprinted
on puts the function in the list for tea:ready. emit finds that list by its name and calls each function in it with "tea".

All of this happens before the next line of code runs. We call this synchronous: emit finishes calling every listener before it returns.

A normal listener, a one-time listener, and cleanupPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const unsubscribeHeader = bus.on("cart:changed", (item, count) => {  console.log(`header ${count} ${item}`);});bus.once("cart:changed", (item) => {  console.log(`analytics first ${item}`);});console.log(bus.emit("cart:changed", "book", 1));unsubscribeHeader();console.log(bus.emit("cart:changed", "pen", 2));

Line 2 adds a normal listener with on and keeps its cleanup function as unsubscribeHeader. Line 5 adds a one-time listener with once: it runs on the next event only. Line 8 sends cart:changed with "book" and 1. It prints header 1 book, then analytics first book, then true.

Line 9 calls the cleanup function, which removes the header listener. The one-time listener is already gone. So the second emit, on line 10, finds no listeners and prints false. Node's emit returns the same thing: true if any listener ran, false if none did.

Calling cleanup twice is safePop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const stop = bus.on("save", () => console.log("saved"));console.log(stop());console.log(stop());console.log(bus.emit("save"));

Line 2 adds a listener and keeps its cleanup function as stop. Line 3 calls stop(). It removes the listener and returns true. Line 4 calls it again. There is nothing left to remove, so it returns false and does no harm. Line 5 sends save, but no listener is left, so it prints false.

Call the cleanup function in the place where the listener's job ends, such as when a part of the page is removed.

Each cleanup function removes its own listenerPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();function shared() { console.log("shared"); }const stopFirst = bus.on("ping", shared);bus.on("ping", () => console.log("middle"));bus.on("ping", shared);stopFirst();bus.emit("ping");

Lines 3 and 5 add the same function, shared, two times. Line 4 adds a middle listener between them. Each call to on stores its own wrapper, so each one has its own cleanup function. Line 6 calls stopFirst, which removes only the first shared. So line 7 prints middle, then shared.

off works differently: it removes the newest match. The next section shows how.

Remove listeners with off and once

NODE EDGES

Removing a listener sounds easy. It gets harder when the same function is in the list two times. Which one should go? Node's emitter removes one of them: the one added most recently. It searches the list from the end. Our off does the same.

off removes the newest copyPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();function handler() {  console.log("handler");}bus.on("ping", handler);bus.on("ping", () => console.log("middle"));bus.on("ping", handler);bus.off("ping", handler);bus.emit("ping");

Line 5 adds handler. Line 6 adds a middle listener. Line 7 adds handler again. Line 8 calls off, which removes the newest handler, the one from line 7. So line 9 prints handler, then middle.

once has its own puzzle. It does not store your function directly. It stores a wrapper: a small function that calls yours. But you still want off(event, original) to work with your own function. So the wrapper keeps your function in a property, wrapper.listener. Node uses the same trick.

When off looks through the list, it asks two questions about each stored function:

  • Is it the function you passed? (candidate === listener)
  • Does its .listener property point to the function you passed? (candidate.listener === listener)
once stores a wrapper in the list. The wrapper holds a .listener card that points to your function, original.stored in the list.listenerwrapperpoints tooriginalyour function
once stores a wrapper, and the wrapper keeps your function on .listener. That is how off("ready", original) still finds it.
Remove a once listener by passing your own functionPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();function original() {  console.log("should not run");}bus.once("ready", original);bus.off("ready", original);console.log(bus.listenerCount("ready"));console.log(bus.emit("ready"));

Line 5 adds original with once, so the list holds a wrapper. Line 6 calls off with original. The wrapper's .listener matches, so the wrapper is removed before the event ever happens. Line 7 prints 0, the number of listeners left, and line 8 prints false.

A once listener can send the same event againPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.once("ready", () => {  console.log("ready");  bus.emit("ready");});console.log(bus.emit("ready"));

Line 2 adds a one-time listener. When it runs, line 4 sends ready again, from inside the listener. The wrapper removed itself before it called your function, so this inner emit finds no listener and does nothing. The output is ready, then true from line 6. There is no second ready.

That is why the wrapper removes itself before it calls your function. If it waited until after, the inner emit on line 4 would still find it in the list and call it again.

emit copies the list first

STEP THROUGH

A listener can add or remove listeners while emit is still running. What should happen to the event that is being sent right now? Node's rule has two parts:

  • A listener removed during an emit still runs in that emit.
  • A listener added during an emit waits for the next emit.

Our emit follows the same rule with one line of the class, line 36: const snapshot = [...listeners]. The spread [...listeners] makes a copy of the array. We call this copy a snapshot. The loop calls the functions in the copy, so changes to the stored list don't affect it.

Changes during an emit show up on the next emitPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const log = [];function first() {  log.push("first");  bus.off("work", second);  bus.on("work", () => log.push("third"));}function second() {  log.push("second");}bus.on("work", first);bus.on("work", second);bus.emit("work");bus.emit("work");console.log(log.join(", "));

Lines 11 and 12 add first and second. Line 13 sends work, and the copy holds first and second. While first runs, line 5 removes second and line 6 adds a new third listener. Both changes go to the stored list, not to the copy. So second still runs in this emit.

Line 14 sends work again. Now the copy holds first and third. Line 15 prints first, second, first, third.

During the first emit. Left: the copy, made before any listener runs, holds first and second, so this emit runs first, then second. Right: the stored list, after first removed second and added third, holds first and third, so the next emit runs first, then third.copy, made firstfirstsecondcopythis emit: first, secondstored list, after firstfirstthirdstorednext emit: first, third
emit copies the list before it calls anyone. Changes made during the emit go to the stored list, so they show up on the next emit.

Now step through a longer run. One listener removes itself during the emit, and a once listener removes itself before it runs. Watch two things: how many save listeners are stored, and what the copy holds.

Listeners remove themselves during an emit
Step 0 of 16Ready
Your turn: follow the blue line

Step through a real run of the emitter: one listener removes itself during emit, a once listener removes itself before it runs, and the copy keeps the current emit the same.

Running in
  1. script
Next: line 106
class EventEmitter {  #events = new Map();  #maxListeners = 10;  #cleanup = new WeakMap();  #warned = new Set();   on(event, listener, options = {}) {    const stored = (...args) => listener(...args);    stored.listener = listener;    return this.#subscribe(event, stored, options);  }   once(event, listener, options = {}) {    const wrapper = (...args) => {      this.#removeStored(event, wrapper, true);      listener(...args);    };    wrapper.listener = listener;    return this.#subscribe(event, wrapper, options);  }   off(event, listener) {    this.#removeStored(event, listener, false);    return this;  }   emit(event, ...args) {    const listeners = this.#events.get(event);    if ((!listeners || listeners.length === 0) && event === "error") {      const error = args[0];      throw error instanceof Error ? error : new Error(        "Unhandled error event: " + String(error),      );    }    if (!listeners || listeners.length === 0) return false;    const snapshot = [...listeners];    for (const listener of snapshot) {      listener(...args);    }    return true;  }   listenerCount(event) {    return this.#events.get(event)?.length ?? 0;  }   removeAllListeners(event) {    const events = event === undefined ? [...this.#events.keys()] : [event];    for (const name of events) {      const listeners = this.#events.get(name) ?? [];      for (const listener of listeners) this.#cleanup.get(listener)?.();      this.#events.delete(name);      this.#warned.delete(name);    }    return this;  }   setMaxListeners(count) {    if (!Number.isInteger(count) || count < 0) {      throw new RangeError("count must be a non-negative integer");    }    this.#maxListeners = count;    return this;  }   #subscribe(event, stored, options) {    if (options.signal?.aborted) return () => false;    let removed = false;    const unsubscribe = () => {      if (removed) return false;      removed = this.#removeStored(event, stored, true);      return removed;    };    if (options.signal) {      const abort = () => unsubscribe();      this.#cleanup.set(stored, () => options.signal?.removeEventListener("abort", abort));      options.signal.addEventListener("abort", abort, { once: true });    }    const listeners = this.#events.get(event) ?? [];    listeners.push(stored);    this.#events.set(event, listeners);    if (this.#maxListeners > 0 && listeners.length > this.#maxListeners && !this.#warned.has(event)) {      this.#warned.add(event);      console.warn("Possible EventEmitter memory leak detected. " + listeners.length + " " + event + " listeners added.");    }    return unsubscribe;  }   #removeStored(event, listener, exact) {    const listeners = this.#events.get(event);    if (!listeners) return false;    for (let index = listeners.length - 1; index >= 0; index -= 1) {      const candidate = listeners[index];      if (candidate === listener || (!exact && candidate.listener === listener)) {        listeners.splice(index, 1);        this.#cleanup.get(candidate)?.();        this.#cleanup.delete(candidate);        if (listeners.length === 0) this.#events.delete(event);        return true;      }    }    return false;  }} const stopLogger = bus.on("save", (value) => {  console.log("logger " + value);  stopLogger();});bus.once("save", (value) => console.log("once " + value));bus.on("save", (value) => console.log("tail " + value));bus.emit("save", "draft");bus.emit("save", "final");
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The lesson's tests run the same cases on Node's built-in emitter. Read each row of the table below as: "for this case, does our emitter act like Node's, and how do we know?"

Does our emitter act like Node's?
The caseSame as Node?How we know
emit calls listeners right away, in orderYesListeners run before emit returns, in the order they were added.
off with a function added twiceYesBoth remove the newest copy. The lesson test runs the same code on node:events.
off(event, original) after onceYesThe wrapper keeps your function on .listener, so off finds it.
Adding or removing listeners during an emitYesA removed listener still runs in the current emit. A new listener waits for the next one.
A listener throwsYesThe error leaves emit, and later listeners don't run.
What on returnsNo, on purposeNode returns the emitter. Ours returns a cleanup function, so removing a listener is easy to remember.

Errors, thrown listeners, and forgotten cleanup

POLICY

Node treats the event name error as special. If you send error and no listener is waiting for it, emit throws the error. An error that nobody handles should not disappear quietly, so our emitter does the same. When an error listener exists, error is a normal event.

An error event with a listenerPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.on("error", (error) => console.log("handled " + error.message));console.log(bus.emit("error", new Error("offline")));

Line 2 adds an error listener. Line 3 sends an Error with the message offline. The listener prints handled offline, and line 3 prints true.

An error event with no listener throwsPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.emit("error", new Error("boom"));

Here no one listens for error, so line 2 throws Error: boom. This is on purpose.

A listener that throws is a different case. An error event is a message you send on purpose. A throw is a crash inside a listener. Our emit does not catch it. The error leaves emit right away, and the listeners after it don't run. Node does the same. This keeps the error easy to see, with its real stack trace: the list of function calls that led to it.

A listener that throws stops the emitPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const log = [];bus.on("save", () => {  log.push("first");  throw new Error("boom");});bus.on("save", () => log.push("second"));try {  bus.emit("save");} catch (error) {  log.push("caught " + error.message);}console.log(log.join(", "));

Line 3 adds a listener that saves first and then throws, on line 5. Line 7 adds a second listener. Line 9 sends save inside a try block. The first listener throws, so the second never runs. Line 11, inside catch, saves the message. Line 13 prints first, caught boom.

An async function runs until its first await. Then it pauses and returns a promise. The next example shows what emit does with that promise.

emit does not wait for an async listenerPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.on("save", async () => {  console.log("listener starts");  await Promise.resolve();  console.log("listener resumes");});console.log(bus.emit("save"));console.log("caller continues");

Line 3 prints listener starts. Line 4 pauses the listener. emit does not wait for the promise, so it returns true right away, and line 7 prints it. Line 8 prints caller continues. Only after that does the listener go on and print listener resumes on line 5.

Our emitter also ignores that promise if it fails. Node's emitter has an option, captureRejections, that sends such failures to the error event.

Listeners that are never removed waste memory. This is called a memory leak. It happens when code adds a listener again and again and never removes it. Each listener stays in the list, and so does everything the listener uses.

Node warns when one event has more than 10 listeners, and our class does too. You can change that limit with setMaxListeners. But the warning is a hint, not a fix. Before you raise the limit, check that each listener gets removed.

The next example uses two words from page frameworks. To mount a part means to put it on the page. To unmount it means to take it off.

Remove the listener when the cart header goes awayPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();function mountCartHeader(emitter, cart) {  return emitter.on("cart:changed", () => console.log(cart.owner + " cart updated"));}const cart = { owner: "Asha" };const unmount = mountCartHeader(bus, cart);bus.emit("cart:changed");unmount();console.log(bus.emit("cart:changed"));

Lines 2 to 4 define mountCartHeader. Line 3 adds a listener that uses cart and returns its cleanup function. Because the listener uses cart, the emitter keeps cart in memory for as long as the listener is in the list.

Line 6 mounts the header and keeps the cleanup function as unmount. Line 7 prints Asha cart updated. Line 8 calls unmount(), which removes the listener. Line 9 prints false: no listener is left. In a page framework, return this cleanup function from the code that added the listener, so it runs when the part is unmounted.

The next two examples use Node's own emitter. They only run in Node.js, so they have no Edit & run button.

Node crashes on an error event with no listenerJavaScript
import { spawnSync } from "node:child_process"; const env = Object.fromEntries(  Object.entries(process.env).filter(([key]) => !key.startsWith("NODE_TEST_")),);const child = spawnSync(process.execPath, [  "-e",  "const { EventEmitter } = require('node:events'); new EventEmitter().emit('error', new Error('boom'));",], { env, encoding: "utf8" }); console.log(child.status);console.log(child.stderr.includes("Unhandled 'error' event"));

Line 6 starts a second Node process, called a child process. That process sends an error event with no listener. Line 11 prints the process's exit code, which is not 0 because it crashed. Line 12 prints true, because the error message says Unhandled 'error' event.

Node warns when one event has too many listenersJavaScript
import { EventEmitter } from "node:events"; const bus = new EventEmitter();bus.setMaxListeners(1);bus.on("data", () => {});bus.on("data", () => {}); // Node prints MaxListenersExceededWarning to stderr.console.log(bus.listenerCount("data"));

Line 4 sets the limit to 1. Line 6 adds a second listener, so Node prints a MaxListenersExceededWarning. Both listeners are still added, so line 7 prints 2.

Why these two examples don't run here

They load Node's built-in modules, which a web page can't load. The lesson's tests run them in Node instead. Lines 3 to 5 of the first example remove the NODE_TEST_* settings before the child process starts, so the test runner doesn't change the result.

Turn an event into an Observable

ADAPTER

An Observable is an object with a subscribe method. You pass it a function, or an object with a next method, and it calls that function with each new value. subscribe returns a subscription: an object with an unsubscribe() method that stops the values. You built a tiny one in the functors & monads lesson.

The fromEvent function below makes one event of our emitter look like an Observable:

  • subscribe calls emitter.on and keeps the cleanup function it gets back.
  • unsubscribe() is that cleanup function, so it removes the emitter's listener.
fromEvent: one emitter event as an ObservablePop out in the code editor (opens in a new tab)JavaScript
function fromEvent(emitter, name) {  return {    subscribe(observer) {      const next = typeof observer === "function" ? observer : observer.next?.bind(observer);      if (!next) return { unsubscribe() {} };      const unsubscribe = emitter.on(name, (...args) => {        next(args.length > 1 ? args : args[0]);      });      return { unsubscribe };    },  };} const bus = new EventEmitter();const clicks = fromEvent(bus, "click");const subscription = clicks.subscribe({  next([x, y]) {    console.log(`click at ${x},${y}`);  },});bus.emit("click", 10, 20);subscription.unsubscribe();bus.emit("click", 30, 40);

Line 4 accepts either a plain function or an object with next. For an object, .bind(observer) makes sure this inside next is still that object. Line 6 adds the emitter listener. Line 7 passes one value on: the only argument, or an array when there are several.

Line 21 sends a click with 10 and 20, so the observer prints click at 10,20. Line 22 unsubscribes. Line 23 sends another click, but no listener is left, so nothing prints.

unsubscribe removes the emitter's listener
Step 0 of 10Ready
Your turn: follow the blue line

Step through fromEvent: subscribe calls on, unsubscribe() calls the cleanup function, and later clicks reach no one.

Running in
  1. script
Next: line 119
class EventEmitter {  #events = new Map();  #maxListeners = 10;  #cleanup = new WeakMap();  #warned = new Set();   on(event, listener, options = {}) {    const stored = (...args) => listener(...args);    stored.listener = listener;    return this.#subscribe(event, stored, options);  }   once(event, listener, options = {}) {    const wrapper = (...args) => {      this.#removeStored(event, wrapper, true);      listener(...args);    };    wrapper.listener = listener;    return this.#subscribe(event, wrapper, options);  }   off(event, listener) {    this.#removeStored(event, listener, false);    return this;  }   emit(event, ...args) {    const listeners = this.#events.get(event);    if ((!listeners || listeners.length === 0) && event === "error") {      const error = args[0];      throw error instanceof Error ? error : new Error(        "Unhandled error event: " + String(error),      );    }    if (!listeners || listeners.length === 0) return false;    const snapshot = [...listeners];    for (const listener of snapshot) {      listener(...args);    }    return true;  }   listenerCount(event) {    return this.#events.get(event)?.length ?? 0;  }   removeAllListeners(event) {    const events = event === undefined ? [...this.#events.keys()] : [event];    for (const name of events) {      const listeners = this.#events.get(name) ?? [];      for (const listener of listeners) this.#cleanup.get(listener)?.();      this.#events.delete(name);      this.#warned.delete(name);    }    return this;  }   setMaxListeners(count) {    if (!Number.isInteger(count) || count < 0) {      throw new RangeError("count must be a non-negative integer");    }    this.#maxListeners = count;    return this;  }   #subscribe(event, stored, options) {    if (options.signal?.aborted) return () => false;    let removed = false;    const unsubscribe = () => {      if (removed) return false;      removed = this.#removeStored(event, stored, true);      return removed;    };    if (options.signal) {      const abort = () => unsubscribe();      this.#cleanup.set(stored, () => options.signal?.removeEventListener("abort", abort));      options.signal.addEventListener("abort", abort, { once: true });    }    const listeners = this.#events.get(event) ?? [];    listeners.push(stored);    this.#events.set(event, listeners);    if (this.#maxListeners > 0 && listeners.length > this.#maxListeners && !this.#warned.has(event)) {      this.#warned.add(event);      console.warn("Possible EventEmitter memory leak detected. " + listeners.length + " " + event + " listeners added.");    }    return unsubscribe;  }   #removeStored(event, listener, exact) {    const listeners = this.#events.get(event);    if (!listeners) return false;    for (let index = listeners.length - 1; index >= 0; index -= 1) {      const candidate = listeners[index];      if (candidate === listener || (!exact && candidate.listener === listener)) {        listeners.splice(index, 1);        this.#cleanup.get(candidate)?.();        this.#cleanup.delete(candidate);        if (listeners.length === 0) this.#events.delete(event);        return true;      }    }    return false;  }} function fromEvent(emitter, name) {  return {    subscribe(observer) {      const next = typeof observer === "function" ? observer : observer.next?.bind(observer);      if (!next) return { unsubscribe() {} };      const unsubscribe = emitter.on(name, (...args) => {        next(args.length > 1 ? args : args[0]);      });      return { unsubscribe };    },  };} const clicks = fromEvent(bus, "click");const subscription = clicks.subscribe({  next([x, y]) {    console.log(`click at ${x},${y}`);  },});bus.emit("click", 10, 20);subscription.unsubscribe();bus.emit("click", 30, 40);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Browser tools often use an AbortSignal to stop things. You get one from an AbortController: calling controller.abort() tells everyone using its signal to stop. Our on accepts { signal } as a third argument, like the browser's addEventListener. When the signal aborts, the listener is removed. One controller can remove many listeners at once.

Abort a controller to remove a listenerPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const group = new AbortController();bus.on("message", (text) => console.log("message " + text), {  signal: group.signal,});bus.emit("message", "before abort");group.abort();console.log(bus.listenerCount("message"));console.log(bus.emit("message", "after abort"));

Line 2 makes a controller called group. Lines 3 to 5 add a listener with its signal. Line 6 prints message before abort. Line 7 aborts, which removes the listener. Line 8 prints 0, and line 9 prints false.

Your emitter, Node's emitter, and the browser's EventTarget

CHOICES

You don't need your own emitter everywhere. The platforms already have one:

  • In the browser, EventTarget is the built-in event system. Page elements use it, and browser tools understand it. It has options such as once and signal.
  • In Node.js, EventEmitter from node:events is the standard. Streams, servers, and many packages use it.
  • Your own emitter is a good fit when you control the code and want a small event system inside your app.
The browser's EventTarget with CustomEvent and oncePop out in the code editor (opens in a new tab)JavaScript
const target = new EventTarget();target.addEventListener("cart:changed", (event) => {  console.log("header " + event.detail.count);}, { once: true });target.dispatchEvent(new CustomEvent("cart:changed", {  detail: { count: 1 },}));target.dispatchEvent(new CustomEvent("cart:changed", {  detail: { count: 2 },}));

An EventTarget listener gets one Event object. To send your own data, use a CustomEvent and put the data in its detail property. Line 4 passes { once: true }, so the listener runs one time only. Line 5 sends a count of 1, and the listener prints header 1. Line 8 sends a count of 2, but the listener is gone, so nothing prints.

EventTarget keeps going after a listener throwsPop out in the code editor (opens in a new tab)JavaScript
const target = new EventTarget();target.addEventListener("save", () => {  console.log("first");  throw new Error("boom");});target.addEventListener("save", () => console.log("second"));console.log(target.dispatchEvent(new Event("save")));

In the browser, dispatchEvent does not throw a listener's error back to your code. It reports the error as uncaught and moves on to the next listener. So this code prints first, second, then true. Our emitter and Node's emitter work differently: a throw stops the emit, and later listeners don't run.

Left: our emitter. The first listener throws, so the second is skipped. The log is first, caught boom. Right: the browser's EventTarget. The first listener throws, the error is reported, and the second still runs. The logs are first, second, true.our emitterfirstsecondthrowsskippedlog: first, caught boombrowser EventTargetfirstsecondthrowsstill runslogs: first, second, true
In our emitter, a throw leaves emit right away, so second never runs. The browser's EventTarget reports the error and still calls second.
Our emitter doesn't set this for listenersPop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();function checkReceiver() {  console.log(this === bus);}bus.on("save", checkReceiver);bus.emit("save");

Line 5 adds a regular function, not an arrow function. Line 6 sends the event. Our emit calls the listener on its own, not as a method of bus, so this is not bus. Line 3 prints false.

Node's emitter sets this to the emitter for regular functions. Arrow functions don't get their own this in either one. Keep this difference in mind if you reuse code written for Node.

The table below puts the three side by side. Read each row as a question, then read across to see each one's answer.

Our emitter, Node's EventEmitter, and the browser's EventTarget
QuestionOur emitterNode's EventEmitterBrowser EventTarget
How do you add and remove listeners?on, once, off, and the cleanup function on returns.on, once, and off. on returns the emitter, so calls can chain.addEventListener and removeEventListener.
What does a listener receive?Any values you pass: emit(name, ...args).Any values you pass: emit(name, ...args).One Event object. Your own data goes in CustomEvent's detail.
What is this in a regular function listener?It is not the emitter.It is the emitter. Arrow functions keep the this from around them.It is the EventTarget the listener is on.
How does once work?A wrapper removes itself, then calls your function. It keeps your function on .listener.The same idea. rawListeners() shows the wrappers.Pass the option { once: true }.
How do you remove many listeners?Call the cleanup functions, off, or removeAllListeners, or abort a { signal }.Call off or removeAllListeners. events.on() and events.once() also accept a signal.Call removeEventListener, or abort a { signal }.
What happens with errors?An error event with no listener throws. A listener that throws stops the emit.An error event with no listener throws. A listener that throws stops the emit.There is no special error event. A listener's error is reported as uncaught, and later listeners still run.
When should you use it?For learning, and for small event systems inside an app you control.For Node streams, servers, command-line tools, and packages.For page events in the browser, and your own events that other browser code should understand.
Which part of the emitter is this line about?
  • `events.get(name) ?? []`
  • `const stop = emitter.on(name, fn)`
  • `wrapper.listener = original`
  • `const snapshot = [...listeners]`
  • `emit("error", error)` with no listener
  • `on(name, fn, { signal })`
Try it yourself
0 of 6 correct

Put each line of code in its group: storing listeners, once and off, how emit runs, or cleanup.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Try it: add, remove, and send events

PLAYGROUND

The playground shows the listener list that is normally hidden. Try this:

  1. Press Emit cart:changed. The header and the one-time analytics listener both run. Then analytics is removed.
  2. Press Toggle logger to add a logger, then emit again.
  3. Press Remove header, press Toggle logger again to remove the logger, then emit. No listener is left, so emit returns false.
EventEmitter playground
Playground starting codePop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();const stopHeader = bus.on("cart:changed", (item) => {  console.log("Header saw " + item);});bus.once("cart:changed", (item) => {  console.log("Analytics only tracks first " + item);});bus.emit("cart:changed", "book");stopHeader();
Listeners nowevents sent: 0
Listener table
EventListenerAdded withIn the list?
cart:changedHeader badgeonyes
cart:changedAnalytics, first item onlyonceyes
cart:changedLoggeronremoved
Recent log
  1. Header and analytics are listening for cart:changed.
Try it yourself

2 listeners are in the list. The once listener will be removed after the next emit.

Send events and add or remove listeners while you watch the listener table. Reset builds a new emitter with the starting listeners.

Common misconceptions

  • "Emitters wait and run listeners later." Our emitter and Node's EventEmitter call listeners right away. If a listener is slow, emit is slow too.
  • "A Set is always better for listeners." A Set can't hold the same function twice. An array keeps the order and lets off remove one copy at a time.
  • "once can remove itself after it runs." It must remove itself first. Otherwise, if the listener sends the same event again, it gets called a second time.
  • "Catching every listener error is safer." It can hide bugs. Our emitter acts like Node: a thrown error leaves emit and stops the listeners after it.
  • "A too-many-listeners warning means raise the limit." First look for listeners that are never removed, such as a page part that adds a listener every time it appears.
  • "Observables replace emitters." An Observable can sit on top of an emitter. What matters is that unsubscribe() removes the emitter's listener.

Before other people use your emitter, write down its rules. Read each row of the table below as a question about the emitter and our answer.

Rules to write down before others use your emitter
QuestionOur answer
Does emit call listeners right away?Yes. Say so in your docs, and keep each listener's work small.
What happens when a listener throws?The error leaves emit. Put try and catch around emit if you want to stop it there.
How do people remove listeners?Call the cleanup function that on returns, call off like in Node, or abort an AbortSignal to remove a group.
Can listeners add or remove listeners during an emit?Yes. Because emit works on a copy, those changes only affect the next emit.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict what emit prints

Read the code and type the three printed lines in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();
bus.on("ready", () => console.log("A"));
bus.on("ready", () => console.log("B"));
console.log(bus.emit("ready"));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeWrite once

    This code sends ready two times, but the listener should print only once. Write a once method that makes that true, then type what the code prints.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const bus = new EventEmitter();
    bus.once("ready", () => console.log("only once"));
    bus.emit("ready");
    bus.emit("ready");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhy does second still print?

      The first listener removes second, but second still prints. Type the two printed lines, then explain which part of emit makes that happen.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const listeners = [];
      function on(listener) { listeners.push(listener); }
      function off(listener) { listeners.splice(listeners.lastIndexOf(listener), 1); }
      function emit() {
        for (const listener of [...listeners]) listener();
      }
      function second() { console.log("second"); }
      on(() => { console.log("first"); off(second); });
      on(second);
      emit();

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · ChallengeAdd a wildcard listener

        A wildcard listener hears every event. This class lets listeners on * do that. Read it and type what the * listener prints. Then try to write the class yourself without looking.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        class WildcardEmitter extends EventEmitter {
          emit(event, ...args) {
            const exactRan = super.emit(event, ...args);
            const wildcardRan = event === "*" ? false : super.emit("*", event, ...args);
            return exactRan || wildcardRan;
          }
        }
        const bus = new WildcardEmitter();
        bus.on("*", (event, value) => console.log(event + ":" + value));
        bus.emit("save", 3);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAdd a signal option

          Type the two printed lines. Then, as a challenge, write your own version of on(event, fn, { signal }) so that aborting removes the listener.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const bus = new EventEmitter();
          const controller = new AbortController();
          bus.on("tick", () => console.log("tick"), { signal: controller.signal });
          bus.emit("tick");
          controller.abort();
          console.log(bus.emit("tick"));

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Node's on returns the emitter. Why does our on return a cleanup function instead?

              Choose an answer to see the explanation.

            2. Question 2 of 7What prints from the basic emitter snippet?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bus = new EventEmitter();
              bus.on("ready", () => console.log("A"));
              bus.on("ready", () => console.log("B"));
              console.log(bus.emit("ready"));

              Choose an answer to see the explanation.

            3. Question 3 of 7The same function is added twice. Which one does Node's off remove?

              Choose an answer to see the explanation.

            4. Question 4 of 7Why does the once wrapper remove itself before it calls your function?

              Choose an answer to see the explanation.

            5. Question 5 of 7What does the snapshot example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bus = new EventEmitter();
              const log = [];
              function a() { log.push("a"); bus.off("x", b); bus.on("x", () => log.push("c")); }
              function b() { log.push("b"); }
              bus.on("x", a);
              bus.on("x", b);
              bus.emit("x");
              bus.emit("x");
              console.log(log.join(","));

              Choose an answer to see the explanation.

            6. Question 6 of 7What is special about emit("error", error)?

              Choose an answer to see the explanation.

            7. Question 7 of 7What does the AbortSignal example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bus = new EventEmitter();
              const controller = new AbortController();
              bus.on("tick", () => console.log("tick"), { signal: controller.signal });
              bus.emit("tick");
              controller.abort();
              console.log(bus.emit("tick"));

              Choose an answer to see the explanation.

            Key takeaways

            • Keep listeners in an array for each event name. The array keeps their order and allows the same function twice.
            • Have on return a cleanup function, so removing one listener is a single call.
            • For once, store a wrapper, keep your function on wrapper.listener, and remove the wrapper before calling your function.
            • Copy the listener array before calling anyone, so changes during an emit only affect the next emit.
            • Follow Node's rules for errors: an error event with no listener throws, and a listener that throws stops the listeners after it.
            • Connect to Observables and AbortSignal by having them call the cleanup function.

            Remember the one-liner.
            An event emitter is a list of functions for each event name, plus clear rules for removing them, copying them, and handling errors.

            Up next: build a small library. There, an event system like this one can let other code plug into the library.

            CompleteFrontend Clear concepts. Working examples.