cf.completefrontendCode editorOpen lab
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
    Design subscriptionsStore listeners by event name, return cleanup functions, and expose listenerCount and bulk removal.
  • 02
    Match tricky emitter behaviorImplement once, off, snapshot emit order, special error events, and synchronous thrown-listener policy.
  • 03
    Connect cleanup to real APIsAdapt the emitter to Observables, AbortSignal, EventTarget, and Node's EventEmitter trade-offs.

Build the contract before the class

The observer & pub/sub lesson already explains why decoupled reactions are useful. The functors & monads lesson already built a tiny Observable. This lesson links back to those ideas and does the hands-on engineering: a real EventEmitter with edge cases you can test.

Plain definition

An event emitter stores listener functions by event name. Calling emit(name, ...args) synchronously calls the current listeners for that name, in registration order, with those arguments.

Real-life analogyA front desk message board

Think of an emitter as a desk with named message trays. People subscribe to trays they care about. When a message lands in a tray, the desk calls everyone currently listed for that tray.

In real life: A guest leaves a message under a topic
In JavaScript: emit(event, ...args) publishes one named event
In real life: Staff sign up for topics
In JavaScript: on(event, listener) stores callbacks in a list
In real life: A temporary note is removed after delivery
In JavaScript: once(event, listener) removes itself before calling
In real life: Staff cross their name off
In JavaScript: The returned unsubscribe or off removes a registration

Where the analogy stops: A real front desk might deliver messages later. This emitter is synchronous like Node: listeners run before emit returns.

We will rely on closures for private state, events and custom events for browser comparisons, cancellation for AbortSignal, and memory management for leak warnings. The neighboring classic utilities lesson covers helpers such as once as standalone utilities; here once belongs to the emitter contract.

Emitter requirements and edge cases
RequirementDecision in this lesson
Register listenerson(event, listener) appends to a Map<event, listener[]> list and returns an unsubscribe function for that exact registration.
Emit return valueemit(event, ...args) calls listeners synchronously in registration order and returns true only when at least one listener ran.
Duplicate functionsIf the same function is registered twice, off(event, fn) removes the most recently added matching registration, like Node.
once removalonce stores a wrapper with .listener = original, removes itself before calling, and can be removed with off(event, original).
Mutation during emitemit snapshots the listener array first, so additions and removals affect the next emit, not the current one.
Unhandled errorEmitting error with no error listener throws the Error object, matching Node's important safety rule.

Subscriptions: Map<event, listener[]>

REAL CLASS

Start with the storage model. Each event name points at an array of listeners. Arrays, not sets, are intentional: registering the same function twice creates two registrations, and order matters. This implementation chooses to have on return an unsubscribe function instead of Node's chainable this. Cleanup is the value UI code and Observable adapters need most; methods such as off, removeAllListeners, and setMaxListeners still return this for chaining.

Production-quality lesson EventEmitterPop 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 = options.signal ? (...args) => listener(...args) : listener;    if (options.signal) 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 small passes. Lines 2 through 5 hold private state. Lines 7 through 10 add a listener and return cleanup. Lines 28 through 43 define the synchronous emit path. The private #subscribe method centralizes returned cleanup and optional AbortSignal support.

on, once, emit, and returned 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));

The first emit prints the header, prints analytics once, then returns true. After unsubscribeHeader(), the second emit has no listeners and returns false. That boolean matches Node: it tells you whether any listener ran.

`once` and `off`: identity is the hard part

NODE EDGES

Removing listeners sounds simple until the same function is registered more than once. Node removes one registration, searching from the end. This class matches that behavior: the most recently added matching function is removed.

off removes the most recent duplicatePop 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");

The remaining order is handler, then middle. The second handler registration was the newest match, so off removed only that one.

once adds a second identity problem: the stored function is a wrapper, but callers expect off(event, original) to work. The wrapper keeps a .listener reference to the original function, the same idea Node uses.

Remove a once listener with the original 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"));

Because off checks both candidate === listener and candidate.listener === listener, the wrapper is removed before the event fires. The count is 0 and emit returns false.

Emit order: snapshot first, then call

STEP THROUGH

The dangerous case is a listener mutating the listener list while an emit is already in progress. Node snapshots enough state that a removed listener still runs during the current emit, while a newly added listener waits until the next emit. Copying the array with [...listeners] gives our class that behavior.

Removing and adding during emit affects 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(", "));

The output is first, second, first, third. The first emit's snapshot still contains second. The third listener is added too late for that snapshot and appears on the second emit.

Emit with once and unsubscribe
Step 0 of 16Ready
Your turn: follow the blue line

Step through a real emitter run: a regular listener unsubscribes during emit, a once listener removes itself before calling, and the copied snapshot keeps the current emit stable.

Running in
  1. script
Next: line 106
Click the blue line to take the next stepPop 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 = options.signal ? (...args) => listener(...args) : listener;    if (options.signal) 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");
CallStoreChangeResultRun = next line. 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.
Conformance checks against Node's events module
BehaviorLesson emitterEvidence
emit is synchronous and orderedMatchesListeners run immediately in registration order.
Duplicate off removes most recentMatchesThe lesson test compares this emitter with node:events.
off(event, original) removes a once wrapperMatchesThe wrapper stores the original function on .listener.
Mutating listeners during emitMatchesNode snapshots enough that removed listeners still run in the current emit and new listeners wait.
Listener throwsMatchesThe throw propagates and later listeners do not run.
on return valueIntentional differenceNode returns this; this lesson returns an unsubscribe function to make cleanup harder to forget.

Error events, thrown listeners, and leaks

POLICY

Node treats error as special: emitting it without an error listener throws. This class matches that rule because silently ignoring an error event hides serious failures. With a listener, the event behaves like any other event.

Handled error eventPop 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")));
Unhandled error event throws on purposePop out in the code editor (opens in a new tab)JavaScript
const bus = new EventEmitter();bus.emit("error", new Error("boom"));

A listener that throws is different from an error event. Node does not catch listener errors for you: the throw propagates, and later listeners do not run. This lesson chooses the same policy so failures are visible and stack traces stay honest.

A throwing listener stops the current 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(", "));

Long-lived emitters can leak memory when code subscribes repeatedly and forgets cleanup. Node's setMaxListeners warning is a diagnostic, not a fix. Raise the limit only after you understand why that many listeners are expected.

Prove Node's unhandled error in a child processJavaScript
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"));
Node max-listeners warningJavaScript
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"));
Why those two examples are read-only

They import Node built-ins and inspect a child Node process, so they are verified in the lesson tests instead of the browser runner. The child process deletes NODE_TEST_* environment variables before proving Node's unhandled error behavior.

Observables and unsubscribe

ADAPTER

An Observable subscription should own cleanup. The adapter below turns an emitter event into a tiny Observable-like object: subscribe calls on, and the returned subscription's unsubscribe() calls the cleanup function from on. That is the direct bridge to the tiny Observable from the functional lesson.

fromEvent adapter for this emitterPop 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);
Observable unsubscribe calls off
Step 0 of 10Ready
Your turn: follow the blue line

Step through fromEvent: subscribing calls on, unsubscribe() calls the returned cleanup, and later emits do not reach the observer.

Running in
  1. script
Next: line 119
Click the blue line to take the next stepPop 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 = options.signal ? (...args) => listener(...args) : listener;    if (options.signal) 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);
CallStoreChangeResultRun = next line. 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 APIs increasingly use AbortSignal for grouped cleanup. Supporting on(event, fn, { signal }) lets a controller remove many emitter listeners the same way addEventListener can.

AbortSignal removes an emitter 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"));

Compare your emitter with platform emitters

CHOICES

Do not replace the platform for its own job. In the browser, EventTarget and CustomEvent interoperate with DOM tooling and options such as once and signal. In Node, EventEmitter is the standard for streams, servers, and package internals. Your custom emitter is best when you control the API and need a small app-local event system.

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 },}));
Custom emitter, Node EventEmitter, and EventTarget
QuestionLesson emitterNode EventEmitterEventTarget / CustomEvent
Listener APIon, once, off, returned cleanupon, once, off, chainable thisaddEventListener, removeEventListener
Payload shapeAny arguments: emit(name, ...args)Any arguments: emit(name, ...args)One Event object; custom data goes in CustomEvent.detail
onceWrapper removes before calling and keeps .listenerSame concept; rawListeners() exposes wrappersBuilt-in { once: true } option
CleanupUnsubscribe function, off, removeAllListeners, { signal }off, removeAllListeners; recent Node also supports helpers with signalsremoveEventListener or { signal }
Unhandled errorsSpecial error event throws without a listenerSpecial error event throws without a listenerNo special event name; listener throws propagate from dispatch
Best fitLearning, app-local buses, plugins you controlNode streams, servers, CLIs, package internalsBrowser DOM events and interoperable custom events
Sort the emitter design decision
  • `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

Place each card by the design concern it protects. Think about storage, once/off identity, emit order, and cleanup.

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

Experiment with live subscriptions

PLAYGROUND

Use the playground to make the invisible listener array visible. Emit once, watch the once listener disappear, add a logger, remove the header, and see the boolean return value change when no listeners remain.

EventEmitter playground
Playground setupPop 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();
Runtime statecart count 0
Listener table
EventListenerKindStatus
cart:changedHeader badgeonactive
cart:changedAnalytics first itemonceactive
*Manual loggeronremoved
Recent log
  1. Header and analytics are subscribed to cart:changed.
Try it yourself

2 listeners are active. The once listener will deactivate after the next emit.

Add, remove, and emit while watching the listener table. Reset rebuilds the emitter and its registrations.

Common misconceptions

  • “Emitters are automatically async.” This emitter and Node's EventEmitter are synchronous. If a listener is slow, emit is slow.
  • “A Set is always better for listeners.” A set loses duplicate registrations. Arrays preserve order and let off remove one registration at a time.
  • “`once` can remove itself after calling.” Remove before calling so a re-entrant emit from inside the listener does not call it twice.
  • “Catching every listener error is safer.” It can hide bugs. This lesson matches Node: thrown listener errors propagate and stop later listeners.
  • “Max-listener warnings mean increase the limit.” First look for forgotten cleanup, especially components that subscribe on every render or mount.
  • “Observables replace emitters.” An Observable can be an adapter over an emitter. The key is that unsubscribe() must remove the underlying listener.
Policies to state before shipping an emitter
QuestionProduction policy
Are listeners synchronous?Yes. Document it and keep listener work small.
What happens when a listener throws?The throw propagates; callers can catch around emit if they need a boundary.
How do learners clean up?Use the returned unsubscribe for exact registrations, off for Node-style removal, and AbortSignal for groups.
Can listeners mutate during emit?Yes, but the copied snapshot makes those changes affect the next emit.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict emit output

Read the code and type the three console 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 · PracticeImplement once

    Write the missing behavior so the listener prints only once, even though the event emits twice.

    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 · PracticeFix off during emit

      The code already uses the fix. Explain the output that proves a copied snapshot is being used.

      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

        Extend the emitter so listeners on * can observe any event name.

        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

          Implement on(event, fn, { signal }) so 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 7Why does this lesson's on return an unsubscribe function instead of Node's this?

              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 7Which duplicate-removal rule matches Node's EventEmitter?

              Choose an answer to see the explanation.

            4. Question 4 of 7Why does once remove the wrapper before calling the original listener?

              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

            • Store listeners in arrays by event name so order and duplicate registrations are explicit.
            • Return unsubscribe functions from on to make exact cleanup easy.
            • Use wrappers for once, store the original on wrapper.listener, and remove before calling.
            • Copy listener arrays before emitting so mutations affect future emits only.
            • Match Node's important edge policies: unhandled error throws and listener throws stop later listeners.
            • Bridge to Observables and AbortSignal by making unsubscribe call off.

            Remember the one-liner.
            A production emitter is a tiny synchronous dispatcher plus carefully documented cleanup, identity, error, and mutation rules.

            Up next: build a small library, where this event system becomes one possible extension point inside a chainable API.

            CompleteFrontend Clear concepts. Working examples.