Observer and pub/sub in Node and the frontend
An emitter calls its listeners now, in order, inside the caller's stack; a broker hands the message on and returns.
Predict first
Before you read: predict
ExamHall's candidate app tells three parts of the page about its connection status. log appends a line, and drafts.save throws a TypeError while the status is offline:
The connection drops.
What is the whole log, in order?
const status = new EventEmitter();status.on('change', (s) => log(`timer ${s}`));status.on('change', (s) => { log(`autosave ${s}`); drafts.save(s); });status.on('change', (s) => log(`banner ${s}`)); try { status.emit('change', 'offline'); log('emitted');} catch (e) { log(`caught ${e.name}`);}log('after');The 30-second version
A subject keeps an ordered list of listeners per event. emit calls each one synchronously, in registration order, from a copy, and returns after the last. In EventEmitter a throw ends that loop and reaches emit's caller; EventTarget reports it and carries on. subscribe returns an unsubscribe for exactly what it added. A broker's subscribers run elsewhere.
An emitter calls its listeners now, in order, inside the caller's stack; a broker hands the message on and returns.
How it works
Step 1 of 14
EventEmitter, an EventTarget, a store) keeps an ordered list of listener functions for each event name.Analogy: A street flood warden's call list (emergency volunteering)
The flood warden keeps a sheet of the neighbours who asked to be rung if the river reaches its warning mark, in the order they signed.
| On the street | In code |
|---|---|
| on a warning she rings each line in turn, one call at a time, and reports back to the coordinator only after the last | emit calls each listener now, in order, then returns (step 2) |
| a "next warning only" line is crossed out as its number is dialled | once removes, then calls (step 6) |
| a neighbour comes off by quoting the line number she was given when she signed; a friend's name in the same hand does not count | subscribe returns its own unsubscribe; off needs that exact function (steps 5, 11) |
| a call that turns into an emergency ends the round and goes straight to the coordinator; the lines below are not rung | a throw ends emit and reaches its caller (step 7) |
| asked twice to take a neighbour off, a careless warden strikes the bottom line the second time | an unsubscribe that is not safe to call twice (step 12) |
In real terms: a subject is an object holding lists of functions; emit is a loop over one list, run inside the call that asked for it.
Breaks down when: a warden facing an emergency can choose to finish the list later; an EventEmitter cannot, and only an EventTarget carries on (step 9). And a phone round takes minutes, while synchronous only means the next line waits for every listener, however quick.
Contrasting case: the county's flood text-alert service. The warden sends it one message and walks away; whoever subscribed there is told on their own phone, and nobody's trouble comes back to her (a broker, steps 13–14).
Examples, run for real
4.1 ExamHall EXH-2560: two autosaves after the candidate comes back
ExamHall's ConnectionStatus store tells the timer, autosave and banner when the connection changes. Each page visit builds fresh parts and subscribes them:
function createStatus() {
const listeners = new Set();
return {
set(next) { for (const l of [...listeners]) l(next); },
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
};
}
const connection = createStatus();
function mountExamPage() {
const parts = [makeTimer(), makeAutosave(), makeBanner()];
const offs = parts.map((part) => connection.subscribe(part));
return () => offs.forEach((off) => off()); // MR !2860 calls this when the page unmounts
}
4.1 EXH-2560 before MR !2860 (nobody calls the page's unsubscribes): visit, leave, visit again, one 'offline' change -> timer 2 · autosave 2 · banner 2 · drafts saved 2
4.1 MR !2860 (the page's unsubscribes called on leaving): the same steps -> timer 1 · autosave 1 · banner 1 · drafts saved 1 · told in order timer, autosave, banner
Before MR !2860 nobody called the page's unsubscribes, so the first visit's parts still listened after the candidate came back: one status change saved the draft twice. With each subscribe's own unsubscribe called on leaving, one part of each kind remains, notified in the order it subscribed (steps 2 and 11). What the old parts cost in memory is js-memory-leaks-gc's lesson.
4.2 Payments ledger: what one emit call sees
The ledger posts each entry to a statement and a fraud check; the statement's listener switches the fraud check off after it has run once:
const ledger = new EventEmitter();
const fraud = (id) => log(`fraud ${id}`);
const statement = (id) => { log(`statement ${id}`); ledger.off('posted', fraud); };
ledger.on('posted', statement);
ledger.on('posted', fraud);
log(`emit e-1 returned ${ledger.emit('posted', 'e-1')}`);
log(`emit e-2 returned ${ledger.emit('posted', 'e-2')}`);
ledger.off('posted', statement);
log(`emit e-3 returned ${ledger.emit('posted', 'e-3')}`);
4.2 ledger log: statement e-1 · fraud e-1 · emit e-1 returned true · statement e-2 · emit e-2 returned true · emit e-3 returned false
The fraud check, removed during the first emit, still ran for it, because emit had already read its list (step 3); the second posting reached only the statement. Every line a listener logs comes before the returned line of its emit, and false means nobody listened (step 2).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A video player tells three parts of the page when playback stalls, through Node's EventTarget. log appends a line, and buffer.refill() throws a RangeError:
The player stalls once.
What has the log recorded once the stall has been handled and Node is idle?
process.on('uncaughtException', (e) => log(`uncaught ${e.name}`));const player = new EventTarget();player.addEventListener('stall', () => log('metrics'));player.addEventListener('stall', () => { log('rebuffer'); buffer.refill(); });player.addEventListener('stall', () => log('spinner')); try { player.dispatchEvent(new Event('stall')); log('dispatched');} catch (e) { log(`caught ${e.name}`);}log('after');Question 2 of 6
A shop's interview kata: an emitter with on, off and once. In its test, the first add never updates the badge. log appends a line:
Which one-line change to emit makes the test log exactly what Node's EventEmitter logs for the same five calls?
class Emitter { constructor() { this.map = {}; } on(e, fn) { (this.map[e] ??= []).push(fn); } off(e, fn) { const l = this.map[e] ?? []; const i = l.indexOf(fn); if (i >= 0) l.splice(i, 1); } once(e, fn) { const w = (...a) => { this.off(e, w); fn(...a); }; this.on(e, w); } emit(e, ...a) { for (const fn of this.map[e] ?? []) fn(...a); }}const cart = new Emitter();cart.once('add', (sku) => log(`welcome ${sku}`));cart.on('add', (sku) => log(`badge ${sku}`));cart.on('add', (sku) => log(`total ${sku}`));cart.emit('add', 'mug');cart.emit('add', 'pen');Question 3 of 6
A chat room keeps its unread count in a small store. log appends a line:
The title bar closes, and its unmount cleanup then calls the same offTitle again.
What does the log hold after the last line?
function createStore(state) { const listeners = []; return { set(next) { state = next; for (const l of [...listeners]) l(state); }, subscribe(l) { listeners.push(l); return () => listeners.splice(listeners.indexOf(l), 1); }, };}const room = createStore({ unread: 0 });const offList = room.subscribe((s) => log(`list ${s.unread}`));const offTitle = room.subscribe((s) => log(`title ${s.unread}`));const offBell = room.subscribe((s) => log(`bell ${s.unread}`));offTitle();offTitle();room.set({ unread: 3 });Question 4 of 6
ExamHall's grader used to tell analytics and the notifier about each result in its own process. Both now run as processes of their own, subscribed over core NATS, analytics first; sc is a StringCodec and log appends a line to the grader's log:
The grader publishes one result.
Whose callbacks receive it, and what does the grader's log hold?
// grader processtry { nc.publish('results.graded', sc.encode(JSON.stringify({ attempt: 'a-77', score: 41 }))); log('published');} catch (e) { log(`caught ${e.name}`);}// analytics processnc.subscribe('results.graded', { callback: (err, m) => { const r = JSON.parse(sc.decode(m.data)); if (!r.seat) throw new TypeError('result has no seat'); tally(r);} });// notifier processnc.subscribe('results.graded', { callback: (err, m) => notify(JSON.parse(sc.decode(m.data))) });Question 5 of 6
A box office's seat store tells the page's counter and banner how many seats are held. log appends a line:
After the buyer leaves the seat map, stopBanner() runs, but the banner still logs on the next hold.
Which single line, put in place of its counterpart in subscribe, makes the banner log for both holds before stopBanner() and for none after, while the counter logs for all three?
const bus = new EventEmitter();let seats = { held: 0 };function subscribe(fn) { const onChange = () => fn(seats); bus.on('change', onChange); return () => bus.off('change', fn);}function hold(n) { seats = { held: seats.held + n }; bus.emit('change');}const stopCounter = subscribe((s) => log(`counter ${s.held}`));const stopBanner = subscribe((s) => log(`banner ${s.held}`));hold(1);hold(1);stopBanner();hold(2);Question 6 of 6
A payments ledger page wires its posting handler in wire(), which runs again each time the panel re-renders: here it runs 3 times before two postings arrive. onPosted must run once per posting, however often wire() has run, with no flag.
Which wire() body does that?
const onPosted = () => { post.calls++; };function wire() { /* the body chosen below */}Go deeper
Pro tips
- Give every
EventEmitteryou own an'error'listener: an'error'emit with no listener throws, and uncaught it ends the process. When: a socket, a stream or your own emitter can emit'error'(step 8). Verify (RAN,tips.mjs):tip 1 no 'error' listener: emit('error', err) throws err itself (true) tip 1 one 'error' listener: it receives err (true) · emit returns true - Asked to write an emitter with
on,offandonce, write its test first: a listener that removes itself, a listener that throws, and one function added twice; say each expected log line aloud before you run it. When: the curriculum row's first typical question, "Implement a small event emitter with on, off and once." Verify: nothing to run; this is interview advice, and it keys nothing. Status: ADVICE.
Source
A web page can change, so every claim from these pages is labelled as of 2026-10-02, the date read; every behaviour this lesson grades is also executed on Node 24.19.0.
- Node.js docs, Events, the curriculum row's first source (as of 2026-10-02). 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). Steps 2–10 rest on it: "The EventEmitter calls all listeners synchronously in the order in which they were registered."; "Once an event is emitted, all listeners attached to it at the time of emitting are called in order."; "The next time eventName is triggered, this listener is removed and then invoked."; "removeListener() will remove, at most, one instance of a listener from the listener array."; "Throwing within an event listener will not stop the other registered handlers from being invoked."; "any given listener can be registered at most once per event type". - RxJS docs, Observable, the row's second source (as of 2026-10-02): https://rxjs.dev/guide/observable, read from the page's own content file https://rxjs.dev/generated/docs/guide/observable.json, because the page renders its text in the browser (
rxjs-observable.txt). Step 11: "When you subscribe, you get back a Subscription, which represents the ongoing execution." It keys no item. - react.dev, useSyncExternalStore, the row's third source (as of 2026-10-02): https://react.dev/reference/react/useSyncExternalStore (
react-usesyncexternalstore.txt). Step 11: "The subscribe function should return a function that cleans up the subscription." It keys no item; the hook itself isreact-context-vs-external-store's. - NATS docs, Publish-subscribe (as of 2026-10-02), teaching only, for the broker that P4 runs: https://docs.nats.io/learn/core-nats/publish-subscribe (
nats-publish-subscribe.txt). Steps 13–14: "the call hands the message to the server and returns."; "It does not wait for, or report, any subscriber."; "A subscriber is a client that registers interest in a subject and receives a copy of every matching message."
Related lessons
- Read first: SOLID at a glance, with LSP and ISP in TypeScript
- Related: Garbage collection and memory leaks
- Related: Context vs external stores
- Related: Effects as synchronization
- Related: Queue vs log vs pub/sub, BullMQ, RabbitMQ and SQS vs Kafka and JetStream
- Related: The transactional outbox and the dual-write problem
- Related: process.nextTick vs promise microtasks, and starvation
Checked against the tools and docs as of 2026-10-02.