Kata: event emitter (on, off, once, emit)
Remove by identity, exactly once: the wrapper removes itself, an unsubscribe removes its own registration, and no array emit holds is edited.
Predict first
Before you read: predict
ExamHall's proctor gateway hands each alert to its listeners through the emitter below, a kata answer the team merged. forward(alert) shows one alert on the proctor's screen. The console added forward with on when the page loaded, and its "flag the next alert" button added the same forward with once:
const gateway = new Emitter();
gateway.on('alert', forward);
gateway.once('alert', forward);
for (const alert of ['a1', 'a2', 'a3']) gateway.emit('alert', alert);
How many times does forward run for each alert?
export class Emitter { #lists = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); } once(event, fn) { const wrapper = (...args) => { this.off(event, fn); fn(...args); }; wrapper.listener = fn; this.on(event, wrapper); } off(event, fn) { const list = this.#lists.get(event) ?? []; const i = list.findIndex((l) => l === fn || l.listener === fn); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { for (const fn of this.#lists.get(event) ?? []) fn(...args); }}The 30-second version
A kata emitter is a Map from event name to an array it never edits: on and off store a new array, so an emit loops over the one it read. on returns an unsubscribe that works once. once registers a wrapper tagged with fn; it checks a fired flag, removes itself by its own reference, then calls fn.
Remove by identity, exactly once: the wrapper removes itself, an unsubscribe removes its own registration, and no array emit holds is edited.
How it works
Step 1 of 11
on returns an unsubscribe; off(event, fn) also removes a once of fn; emit calls the listeners present when it began.Analogy: A hot-air balloon operator's passenger lists (ballooning)
A balloon operator keeps one passenger list per launch field. When the weather allows a launch there, the crew chief texts everyone on that field's list.
| At the balloon operator | In the kata |
|---|---|
| one list per launch field, in its own tray | a Map from event name to an array (step 2) |
| a change means printing a fresh list for the tray; nobody writes on a list already handed out | copy on write (step 3) |
| on a "go" call the chief texts everyone on the list she took from the tray; a cancellation filed meanwhile is on a newer list | emit reads the array once (step 4) |
| each booking comes with a slip; handing it in cancels that seat, and the office stamps it, so a second hand-in cancels nothing | the one-shot unsubscribe (step 7) |
| "cancel Mr Osei" cancels his latest booking at that field, a regular seat or a voucher seat | off matches fn or its tag, from the end (step 5) |
| a voucher seat is written "one flight, voucher for Mr Osei", comes off the list when he is texted, and is ticked "used" | once: the tagged wrapper removes itself (step 8) |
| a pilot relaying the go from an older list sees the tick and does not text him again | the fired flag (step 9) |
| a field with nobody booked has its tray emptied | delete the empty entry (step 10) |
In real terms: an emitter is a map of arrays and a few rules for replacing them.
Breaks down when: a text takes time and a passenger can reply; emit calls every listener before its next line. A person also holds one seat per booking, while one function can be registered twice.
Contrasting case: the operator's recorded weather line. Anyone can phone it to hear today's call, and nobody is told anything unasked: polling, with no list to keep.
Examples, run for real
4.1 ExamHall EXH-2820: the proctor gateway's sockets that never left
The proctor gateway emits each Redis pub/sub alert on alerts:<examId>, and each proctor's socket subscribes to its exam. Before MR !3120 a closed socket's listener stayed (EXH-2820); the MR calls the function on returned when the socket closes. Redis is stubbed: the example calls the gateway's handler itself.
// before MR !3120
gateway.on(`alerts:${examId}`, (alert) => socket.send(alert));
// MR !3120
const stop = gateway.on(`alerts:${examId}`, (alert) => socket.send(alert));
socket.onClose(stop);
4.1 EXH-2820 before MR !3120: 1000 proctor sockets opened and closed on ECON-201, then 1 alert -> send attempts 1000, 1000 of them to closed sockets
4.1 MR !3120: the same steps -> send attempts 0 · emit returned false
Before the MR every closed socket's listener stays, so one alert goes to every socket ever opened; with it, each close runs that socket's own unsubscribe (step 7). The same file runs the test list on the reference and on two common mutants: M1 writes once without the tag, and M2's on pushes into the stored array.
4.1 reference.mjs 23 of 23 pass (12 core, 11 ladder)
4.1 M1, once without the tag 20 of 23 pass (12 core, 11 ladder) · core red: C9 "off(event, fn) removes a once() listener before it fires" · ladder red: 2
4.1 M2, on pushes into the stored array 22 of 23 pass (12 core, 11 ladder) · core red: C12 "a listener added during an emit is first called at the next emit"
M1 fails the list's off before once fires, because nothing in the array is fn (step 8); M2 fails the mid-emit addition, because the emit in progress holds the array on changed (step 3).
4.2 Chat: the room's listeners change during a message
A chat room previews each message, sends one read receipt and then stops listening for receipts, and the first message also turns on the room's notification sound:
const room = new Emitter();
let soundOn = false;
room.on('message', () => {
log('preview');
if (!soundOn) { soundOn = true; room.on('message', () => log('sound')); }
});
const stopReceipt = room.on('message', () => { log('receipt'); stopReceipt(); });
room.emit('message', 'hi');
room.emit('message', 'again');
4.2 chat, reference.mjs: hi: preview, receipt · again: preview, sound
4.2 chat, M2 (on pushes into the stored array): hi: preview, receipt, sound · again: preview, sound
Each change stored a new array, so the first emit called the two listeners it read, the receipt included; the sound joins from the next message (steps 3, 4). M2's on pushes into the array the first emit is still reading.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A chat room shows each message with showMessage, plays a sound with playSound, and marks the next one read with a once of markRead. Each handler logs its name. The room's emitter is this kata answer:
const room = new Emitter();
room.on('message', showMessage);
room.on('message', playSound);
room.once('message', markRead);
const panel = {};
room.off('message', panel.onTyping);
room.emit('message', 'hi');
The typing panel's cleanup calls off with panel.onTyping, which the panel never set.
Which handlers run for the next message, in order?
export class Emitter { #lists = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); } once(event, fn) { const wrapper = (...args) => { this.off(event, wrapper); fn(...args); }; wrapper.listener = fn; this.on(event, wrapper); } off(event, fn) { const list = this.#lists.get(event) ?? []; const i = list.findLastIndex((l) => l === fn || l.listener === fn); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { for (const fn of this.#lists.get(event) ?? []) fn(...args); }}Question 2 of 8
A video app's autoplay panel shows the next episode when one ends. Each time the viewer opens the panel it calls player.once('ended', showNextUp), and closing the panel calls player.off('ended', showNextUp) once for each opening. showNextUp logs its argument. The player's emitter is this kata answer, which keeps a map from each function to its once wrapper:
const player = new Emitter();
player.once('ended', showNextUp);
player.once('ended', showNextUp);
player.once('ended', showNextUp);
player.off('ended', showNextUp);
player.off('ended', showNextUp);
player.emit('ended', 'ep-4');
The viewer opened the panel three times and closed it twice, so it is open once.
How many times does showNextUp run when the episode ends?
export class Emitter { #lists = new Map(); #wrappers = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); } once(event, fn) { const wrapper = (...args) => { this.off(event, wrapper); fn(...args); }; this.#wrappers.set(fn, wrapper); this.on(event, wrapper); } off(event, fn) { const target = this.#wrappers.get(fn) ?? fn; this.#wrappers.delete(fn); const list = this.#lists.get(event) ?? []; const i = list.lastIndexOf(target); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { for (const fn of this.#lists.get(event) ?? []) fn(...args); }}Question 3 of 8
A payments ledger page subscribes its balance, statement and fraud panels to posted, and each panel calls the function on returned when it closes. A candidate's kata answer is complete apart from the line that removes the listener:
The answer has no once yet, so the 10 core tests of the team's list that do not call once are run.
Which line passes all 10?
export class Emitter { #lists = new Map(); on(event, fn) { const list = [...(this.#lists.get(event) ?? []), fn]; this.#lists.set(event, list); let active = true; return () => { if (!active) return; active = false; /* the removal */ }; } off(event, fn) { const list = this.#lists.get(event) ?? []; const i = list.lastIndexOf(fn); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { const list = this.#lists.get(event) ?? []; for (const fn of list) fn.apply(this, args); return list.length > 0; }}Question 4 of 8
A box office's seat map holds seats through an emitter. Holding one seat of a pair also holds its neighbour, which emits hold again, and the fan's ten-minute timer must start once, at the first hold. log appends a line. The kata answer:
const seats = new Emitter();
seats.on('hold', (seat) => { if (seat === 'A1') seats.emit('hold', 'A2'); });
seats.once('hold', (seat) => log(`timer ${seat}`));
seats.emit('hold', 'A1');
The log reads timer A2, then timer A1: two timers. The team's list has 12 core and 6 ladder tests about on, off, once and emit.
Which one change makes the log a single timer line and keeps all 18 green?
export class Emitter { #lists = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); let active = true; return () => { if (active) this.off(event, fn); active = false; }; } once(event, fn) { const wrapper = (...args) => { this.off(event, wrapper); fn.apply(this, args); }; wrapper.listener = fn; return this.on(event, wrapper); } off(event, fn) { const list = this.#lists.get(event) ?? []; const i = list.findLastIndex((l) => l === fn || l.listener === fn); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { const list = this.#lists.get(event) ?? []; for (const fn of list) fn.apply(this, args); return list.length > 0; }}Question 5 of 8
the interviewer says: "Implement an EventEmitter class with on, off, once and emit. on returns a function that unsubscribes." You have 15 minutes and a plain editor, and ExamHall's proctor gateway, which fans each Redis alert out to the proctors' sockets, is the use case.
Before you type: say your test list out loud.
Name at least eight tests, each with its calls and what it expects.
Question 6 of 8
the interviewer says: "Implement an EventEmitter class with on, off, once and emit. on returns a function that unsubscribes." You have 15 minutes and a plain editor, and ExamHall's proctor gateway, which fans each Redis alert out to the proctors' sockets, is the use case.
Now write it, in 15 minutes, from a blank file that exports class Emitter.
Question 7 of 8
A shop's product page shows each new price with showPrice. When a flash sale is announced, the sale banner adds once('price', flashSale) to flash once at the next price, and if the sale is cancelled first it calls off('price', flashSale). Each handler logs its name. The page's kata answer:
const product = new Emitter();
product.on('price', showPrice);
product.once('price', flashSale);
product.off('price', flashSale);
product.emit('price', 19);
The sale was cancelled, yet the next price logs showPrice, then flashSale.
Which line, in place of const i = list.lastIndexOf(fn);, makes it log only showPrice, and keeps green the 20 tests of the team's list that do not call eventNames()?
export class Emitter { #lists = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); let active = true; return () => { if (active) this.off(event, fn); active = false; }; } once(event, fn) { let fired = false; const wrapper = (...args) => { if (fired) return; fired = true; this.off(event, wrapper); fn.apply(this, args); }; wrapper.listener = fn; return this.on(event, wrapper); } off(event, fn) { if (typeof fn !== 'function') return; const list = this.#lists.get(event) ?? []; const i = list.lastIndexOf(fn); if (i >= 0) this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { const list = this.#lists.get(event) ?? []; for (const fn of list) fn.apply(this, args); return list.length > 0; }}Question 8 of 8
A support agent's chat client shows who is typing in each open room. Opening a room subscribes showTyping to that room's own event, and closing it calls the function on returned. Its kata answer:
const chat = new Emitter();
for (const room of ['r1', 'r2', 'r3']) {
const stop = chat.on(`typing:${room}`, showTyping);
stop();
}
log(chat.eventNames().length);
The agent opens and closes three rooms, and the client logs 3. Its tab stays open all day.
Which one change makes it log 0, and keeps green the 13 tests of the team's list that do not call once?
export class Emitter { #lists = new Map(); on(event, fn) { this.#lists.set(event, [...(this.#lists.get(event) ?? []), fn]); let active = true; return () => { if (active) this.off(event, fn); active = false; }; } off(event, fn) { const list = this.#lists.get(event) ?? []; const i = list.lastIndexOf(fn); if (i < 0) return; this.#lists.set(event, list.toSpliced(i, 1)); } emit(event, ...args) { const list = this.#lists.get(event) ?? []; for (const fn of list) fn.apply(this, args); return list.length > 0; } eventNames() { return [...this.#lists.keys()]; }}Go deeper
Pro tips
- Return the unsubscribe function from
on(). It is the shape of a ReactuseEffectcleanup, and it answers the leak follow-up in one line. When: the first follow-up after the kata, "how do listeners not outlive the component?" (step 7). Verify (RAN,tips.mjs): (the curriculum row's tip, [SELF])tip 1 an effect whose setup returns on()'s function, run as setup, cleanup, setup, cleanup: emit afterwards returned false (no listener left) - Run your own answer against the list before you call it done:
KATA_IMPL=./my-emitter.mjs node --test kata-core.test.mjs. When: practising the kata at home, against the same tests the reference passes (step 11). Verify (RAN,tips.mjs):tip 2 KATA_IMPL=./reference.mjs: exit 0 · tests 12 · pass 12 · fail 0 tip 2 KATA_IMPL=<temp>/m1-emitter.mjs (once without the tag): exit 1 · tests 12 · pass 11 · fail 1 · red: "off(event, fn) removes a once() listener before it fires"
Source
Pages change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed on Node 24.19.0.
- Node.js docs, Events, the curriculum row's first source. The row's URL, https://nodejs.org/api/events.html, served the v26.10.0 documentation on the day read (
node-events-current.txt); the pack quotes the documentation of the line it runs on, https://nodejs.org/docs/latest-v24.x/api/events.html (v24.21.0,node-events-v24.txt). Step 1: "Once an event is emitted, all listeners attached to it at the time of emitting are called in order." Step 5: "removeListener() will remove, at most, one instance of a listener from the listener array." and "removeListener() will remove the most recently added instance." Step 8:rawListeners"Returns a copy of the array of listeners for the event named eventName, including any wrappers (such as those created by .once())." Step 4: "Returns true if the event had listeners, false otherwise." Step 10:eventNames()"Returns an array listing the events for which the emitter has registered listeners." As of 2026-10-04. - Front End Interview Handbook, JavaScript coding (the row's second source), https://www.frontendinterviewhandbook.com/coding/javascript-utility-function (
feih-javascript-coding.txt, page "Last updated on Aug 13, 2026"). It lists "Event Emitter" among its practice questions, says candidates take "around 10-15 minutes for a basic question" (the time box), and advises "writing test cases for them" (step 11). As of 2026-10-04.
Related lessons
Checked against the tools and docs as of 2026-10-04.