Build an event emitter
Build a production-quality JavaScript EventEmitter with on, off, once, emit, error handling, cleanup, and Observable adapters.
- 01Design subscriptionsStore listeners by event name, return cleanup functions, and expose
listenerCountand bulk removal. - 02Match tricky emitter behaviorImplement
once,off, snapshot emit order, specialerrorevents, and synchronous thrown-listener policy. - 03Connect 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.
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.
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
offremoves 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.
| Requirement | Decision in this lesson |
|---|---|
| Register listeners | on(event, listener) appends to a Map<event, listener[]> list and returns an unsubscribe function for that exact registration. |
| Emit return value | emit(event, ...args) calls listeners synchronously in registration order and returns true only when at least one listener ran. |
| Duplicate functions | If the same function is registered twice, off(event, fn) removes the most recently added matching registration, like Node. |
once removal | once stores a wrapper with .listener = original, removes itself before calling, and can be removed with off(event, original). |
| Mutation during emit | emit snapshots the listener array first, so additions and removals affect the next emit, not the current one. |
| Unhandled error | Emitting error with no error listener throws the Error object, matching Node's important safety rule. |
Subscriptions: Map<event, listener[]>
REAL CLASSStart 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.
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.
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 EDGESRemoving 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 duplicateconst 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.
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 THROUGHThe 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.
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.
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.
script
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");| Behavior | Lesson emitter | Evidence |
|---|---|---|
emit is synchronous and ordered | Matches | Listeners run immediately in registration order. |
Duplicate off removes most recent | Matches | The lesson test compares this emitter with node:events. |
off(event, original) removes a once wrapper | Matches | The wrapper stores the original function on .listener. |
| Mutating listeners during emit | Matches | Node snapshots enough that removed listeners still run in the current emit and new listeners wait. |
| Listener throws | Matches | The throw propagates and later listeners do not run. |
on return value | Intentional difference | Node returns this; this lesson returns an unsubscribe function to make cleanup harder to forget. |
Error events, thrown listeners, and leaks
POLICYNode 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.
const bus = new EventEmitter();bus.on("error", (error) => console.log("handled " + error.message));console.log(bus.emit("error", new Error("offline")));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.
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.
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"));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"));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
ADAPTERAn 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.
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);Step through fromEvent: subscribing calls on, unsubscribe() calls the returned cleanup, and later emits do not reach the observer.
script
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);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.
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
CHOICESDo 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.
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 },}));| Question | Lesson emitter | Node EventEmitter | EventTarget / CustomEvent |
|---|---|---|---|
| Listener API | on, once, off, returned cleanup | on, once, off, chainable this | addEventListener, removeEventListener |
| Payload shape | Any arguments: emit(name, ...args) | Any arguments: emit(name, ...args) | One Event object; custom data goes in CustomEvent.detail |
once | Wrapper removes before calling and keeps .listener | Same concept; rawListeners() exposes wrappers | Built-in { once: true } option |
| Cleanup | Unsubscribe function, off, removeAllListeners, { signal } | off, removeAllListeners; recent Node also supports helpers with signals | removeEventListener or { signal } |
| Unhandled errors | Special error event throws without a listener | Special error event throws without a listener | No special event name; listener throws propagate from dispatch |
| Best fit | Learning, app-local buses, plugins you control | Node streams, servers, CLIs, package internals | Browser DOM events and interoperable custom events |
`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 })`
Place each card by the design concern it protects. Think about storage, once/off identity, emit order, and cleanup.
Experiment with live subscriptions
PLAYGROUNDUse 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.
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();| Event | Listener | Kind | Status |
|---|---|---|---|
cart:changed | Header badge | on | active |
cart:changed | Analytics first item | once | active |
* | Manual logger | on | removed |
- Header and analytics are subscribed to cart:changed.
2 listeners are active. The once listener will deactivate after the next emit.
Common misconceptions
- “Emitters are automatically async.” This emitter and Node's
EventEmitterare synchronous. If a listener is slow,emitis slow. - “A Set is always better for listeners.” A set loses duplicate registrations. Arrays preserve order and let
offremove 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.
| Question | Production 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 EXERCISESemit outputRead the code and type the three console lines in order.
const bus = new EventEmitter();
bus.on("ready", () => console.log("A"));
bus.on("ready", () => console.log("B"));
console.log(bus.emit("ready"));The listeners print A and B, then emit returns true, so the full output is A, B, true.
onceWrite the missing behavior so the listener prints only once, even though the event emits twice.
const bus = new EventEmitter();
bus.once("ready", () => console.log("only once"));
bus.emit("ready");
bus.emit("ready");const wrapper = (...args) => { off(event, wrapper); listener(...args); };
wrapper.listener = listener;
on(event, wrapper);The wrapper is the stored listener. Removing it before calling keeps re-entrant emits from running it twice.
off during emitThe code already uses the fix. Explain the output that proves a copied snapshot is being used.
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();for (const listener of [...listeners]) listener();The copied array freezes the current notification order. off(second) affects the next emit, not this one.
Extend the emitter so listeners on * can observe any event name.
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);class WildcardEmitter extends EventEmitter {
emit(event, ...args) {
const exactRan = super.emit(event, ...args);
const wildcardRan = event === "*" ? false : super.emit("*", event, ...args);
return exactRan || wildcardRan;
}
}The wildcard listener receives the event name and the payload, so emit("save", 3) prints save:3.
Implement on(event, fn, { signal }) so aborting removes the listener.
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"));The first emit prints tick. controller.abort() removes the listener, so the second emit has no listeners and returns false.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why does this lesson's
onreturn an unsubscribe function instead of Node'sthis?Choose an answer to see the explanation.
Question 2 of 7What prints from the basic emitter snippet?
Read the code, then predictconst 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.
Question 3 of 7Which duplicate-removal rule matches Node's EventEmitter?
Choose an answer to see the explanation.
Question 4 of 7Why does
onceremove the wrapper before calling the original listener?Choose an answer to see the explanation.
Question 5 of 7What does the snapshot example print?
Read the code, then predictconst 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.
Question 6 of 7What is special about
emit("error", error)?Choose an answer to see the explanation.
Question 7 of 7What does the AbortSignal example print?
Read the code, then predictconst 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
onto make exact cleanup easy. - Use wrappers for
once, store the original onwrapper.listener, and remove before calling. - Copy listener arrays before emitting so mutations affect future emits only.
- Match Node's important edge policies: unhandled
errorthrows and listener throws stop later listeners. - Bridge to Observables and
AbortSignalby making unsubscribe calloff.
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.