Skip to content

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?

Code for this question
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');
Pick one

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

One list per event. A subject (an 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?

Code for this question
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');
Pick one

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?

Code for this question
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');
Pick one

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?

Code for this question
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 });
Pick one

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?

Code for this question
// 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))) });
Pick one

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?

Code for this question
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);
Pick one

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?

Code for this question
const onPosted = () => { post.calls++; };function wire() {  /* the body chosen below */}
Pick one

Go deeper

Pro tips
  1. Give every EventEmitter you 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
  2. Asked to write an emitter with on, off and once, 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 is react-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."

Checked against the tools and docs as of 2026-10-02.