Observer and Pub/Sub

Here is a function that grew one line at a time, the way real code does. Every time somebody needed the cart to also do a new thing, they added a line:

// cart.js
function addItem(item) {
  cart.items.push(item);

  updateHeaderBadge(cart.items.length);
  renderSidebar(cart.items);
  recomputeShipping(cart);
  saveToLocalStorage(cart);
  sendAnalytics("cart_add", item);
}

It works. It also means cart.js now imports the header, the sidebar, the shipping calculator, local storage, and the analytics client. The one file about carts knows about five things that have nothing to do with carts. Add a wishlist counter that reacts to cart changes and you edit addItem again. So does the next person. So does the one after that.

Then the analytics client shipped a version where sendAnalytics threw on a missing property. It sat in the middle of that list. The throw blew up the stack before saveToLocalStorage ran, so the cart silently stopped persisting, and because analytics is the last thing anyone suspects, it took two days to find. One unrelated observer broke an unrelated write, because they were welded into the same function.

The fix is to flip who knows whom. addItem should announce that the cart changed and stop caring who is listening. The header, the sidebar, analytics, all of them, should reach in and say “tell me when that happens.” That inversion is the whole pattern, and you have used it a thousand times without naming it.

The shape you already know

button.addEventListener("click", onClick) is this exact deal. The button is a subject: something that changes. Your callback is an observer: something that wants to know. You never told the button who you are or what you plan to do. You told it one thing, “call this on click,” and it filed you away in a list. Click the button and it walks that list and calls everyone, knowing nothing about any of you beyond “you asked to be notified.” The DOM event model is this pattern scaled up with bubbling on top.

the cart announces a change, oncecartemit(‘change’)notify allheader badgeupdateBadge()sidebar listrenderList()shipping calcrecompute()analyticstrack()the subject holds a list and calls it; the observers decided, on their own, to be on that list
The subject announces a change and every observer reacts. It never names them; it only knows they asked to be told.

Build the emitter yourself

The mechanism is small enough that hiding it behind a library does you a disservice. A subject needs to remember who is listening, let people join and leave, and call everyone on demand. That is three functions and a Map.

// emitter.js
export function createEmitter() {
  const channels = new Map(); // event name -> Set of listeners

  function on(event, fn) {
    if (!channels.has(event)) channels.set(event, new Set());
    channels.get(event).add(fn);
    return () => channels.get(event)?.delete(fn); // hand back the undo
  }

  function off(event, fn) {
    channels.get(event)?.delete(fn);
  }

  function emit(event, ...args) {
    for (const fn of channels.get(event) ?? []) fn(...args);
  }

  return { on, off, emit };
}

Three details earn their place. A Set instead of an array means adding the same function twice is a no-op and removing it is one cheap call, no scanning. Keying by event name lets one subject carry many independent channels, so "change" and "error" do not step on each other. And on returns a function that unsubscribes you. That last one looks like a nicety. It is the single most important line in the file, and the section on leaks is going to lean on it hard.

Now the cart stops calling anyone:

// cart.js, knows nothing about the UI anymore
import { createEmitter } from "./emitter.js";

const events = createEmitter();
export const onCartChange = (fn) => events.on("change", fn);

export function addItem(item) {
  cart.items.push(item);
  events.emit("change", cart); // "something happened." that is the whole job.
}

Every concern subscribes itself, from its own file:

// header.js
import { onCartChange } from "./cart.js";
onCartChange((cart) => updateBadge(cart.items.length));

// analytics.js
import { onCartChange } from "./cart.js";
onCartChange((cart) => track("cart_add", cart.items.at(-1)));

Look at the imports. Before, cart.js reached out to five modules. Now those modules reach in. The dependency arrows reversed, and cart.js has no idea the header or analytics exist. Adding a sixth listener is a new file that imports the cart, and it never touches addItem. That is the payoff: the thing that changes and the things that care are no longer welded together.

The versions the platform ships

You rarely hand-roll this in production, because two good ones come for free. In Node it is EventEmitter:

import { EventEmitter } from "node:events";

const bus = new EventEmitter();
const onTick = (n) => console.log("tick", n);

bus.on("tick", onTick);
bus.once("ready", () => console.log("fires at most once"));
bus.emit("tick", 1); // tick 1
bus.off("tick", onTick); // off() is an alias for removeListener(), Node 10+

once is the one-shot subscription: it removes itself after firing, which saves you the “subscribe, then unsubscribe on the first call” dance for setup events. Two sharp edges are worth committing to memory now, because both show up in the failures section.

In the browser, the subject is any EventTarget, and the modern move is telling it how to let go. Pass an AbortSignal and one abort() removes every listener wired to it:

const ac = new AbortController();

el.addEventListener("scroll", onScroll, { signal: ac.signal });
window.addEventListener("resize", onResize, { signal: ac.signal });
media.addEventListener("change", onTheme, { signal: ac.signal });

// teardown: one line kills all three, no matching removeEventListener calls
ac.abort();

EventTarget is a global in Node now too, so the two worlds have mostly converged. Reach for EventEmitter when you want many arguments per emit or once as a promise; reach for EventTarget when you want the AbortSignal cleanup story and a browser-shaped API. Either way you are wiring up the same subject-and-observers.

Observer is not pub/sub

People use these two names interchangeably and they should not, because the difference is exactly the thing that decides how far your system can scale before it tangles.

In the emitter above, a subscriber still holds a reference to a specific subject. header.js imports the cart. The cart’s Set holds the header’s callback. There is a real, traceable wire between them: the observer knows its subject, the subject knows its observers. That is the classic observer pattern, and its virtue is that the wire is visible. You can read header.js and see exactly what it watches.

Pub/sub cuts the last thread. You put a broker in the middle, an event bus that both sides import instead of importing each other. Publishers call bus.publish("order.paid", data). Subscribers call bus.subscribe("order.paid", fn). Neither one references the other at all. The bus is the only thing they share, and it does not care who is on either end.

Observer: the subject knows the listsubjectobserver 1observer 2observer 3tight, and readable: you can see what each observer watchesPub/Sub: nobody knows anybody, only the buspublisher Apublisher Bevent bus(broker)subscriber 1subscriber 2subscriber 3loose, and scales to many-to-many: the price is you cannot read the wiring off any one file
Observer wires the subject straight to its observers. Pub/sub routes everything through a broker, so publishers and subscribers never reference each other.

That looseness is the point and the price. Pub/sub scales to many-to-many across modules and even services, which is the seed of an event-driven architecture. But when everything talks through a nameless bus, the question “what happens when I publish order.paid?” has no answer you can read. You grep, and you hope the grep found everything. Observer keeps the wire in the code where you can follow it.

So pick by honesty. If the subject is a concrete thing the observer already holds, a DOM node, a store, a model, use observer and keep the wire visible. If you genuinely want modules that must not know each other to coordinate through named events, use a broker. A mediator is the same instinct applied to direct collaborators rather than events. What none of these are is middleware or a chain of responsibility: those pass one request down a line where each link can stop it, not broadcast one fact to everyone at once.

Where this quietly runs everything

Once you see the shape you see it everywhere, because most of the interactive web is built on it.

Reactive state and signals. A signal is a subject holding a value. Read it inside a computation and that computation automatically subscribes; write a new value and every dependent recomputes. It is the observer pattern with the subscription step made invisible. The TC39 Signals proposal (Stage 2 as of 2026) is an attempt to standardize exactly this so Angular, Vue, Solid, Svelte, and Preact can share one reactive core instead of five slightly different ones.

The redux-style store. A store is one subject. store.subscribe(listener) adds an observer and returns, yes, an unsubscribe function. Dispatch an action, the reducer computes the next state, and the store notifies every subscriber, which is how your components know to re-render. React bindings wrap that raw subscribe so you never call it by hand, but underneath it is the emitter you just built.

Model-view bindings. The view observes the model. The model changes, emits, the view redraws. Every UI framework is some elaboration of that one loop, this pattern wearing a bigger coat.

Where it bites: the leak that pages you

Here is the failure that will actually wake you up, and it is a direct consequence of the mechanism. An observer that subscribes and never unsubscribes does not just linger. It keeps its entire world alive.

An object in JavaScript survives as long as something reachable still points at it. Your emitter is reachable, usually a long-lived module-level thing. It holds a Set with your listener in it. The listener is a closure, so it holds the component that created it, which holds that component’s DOM nodes and whatever data it captured. One live reference at the top of that chain pins the whole thing. The user navigated away three screens ago and the component is still in memory, because the emitter is still holding its hand.

LEAK: subscribed on mount, never unsubscribedcartEventslives foreverlistener fna closureCartWidget (unmounted)+ DOM nodes + 1.2 MB rowsholdscloses overreachable, so never collectedFIXED: unsubscribe() on teardown cuts the linkcartEventslisteners: emptylistener fnremovedCartWidgetcollectedcutnothing reachable, GC frees it
The emitter outlives the widget. As long as its listener set holds the closure, the unmounted widget and everything it captured cannot be collected.

The ghost does not just sit there, either. On the next emit it runs, updating a component that is gone, writing to a detached DOM node, often throwing. So you get a memory leak and a stream of errors from code the user cannot see. Multiply by every route change over an afternoon and the tab dies, or in Node the process does.

The fixes are all about that unsubscribe function you were handed and probably threw away:

// keep the undo, call it when the owner goes away
const stop = onCartChange(render);
onUnmount(() => stop()); // the subscription dies with its owner

// or let one AbortSignal own the lifetime of many at once
const ac = new AbortController();
el.addEventListener("input", onInput, { signal: ac.signal });
window.addEventListener("keydown", onKey, { signal: ac.signal });
onUnmount(() => ac.abort()); // both gone, no bookkeeping

Frameworks give you the hook to do this: React’s useEffect cleanup return, Vue’s onBeforeUnmount, Angular’s ngOnDestroy. The rule underneath all of them is the same. Every subscription needs a matching unsubscription tied to the lifetime of whatever subscribed. If you cannot point at the line that removes a listener, you have a leak, you just have not measured it yet.

Where it bites: one throw starves the rest

Remember the analytics client that broke the cart. That is baked into the naive emit:

function emit(event, ...args) {
  for (const fn of channels.get(event) ?? []) fn(...args);
}

A bare loop. If the third listener throws, the loop dies on the spot, the fourth and fifth never run, and the exception propagates straight back to whoever called emit, which was addItem. One buggy observer takes hostage every observer registered after it, plus the code that triggered the notification. Node’s EventEmitter does exactly this, by the way. It does not isolate listeners for you.

Naive loopfor (fn of set) fn(args)listener 1 · ranlistener 2 · ranlistener 3 · throwslistener 4 · never runslistener 5 · never runsemit() throws → addItem() breaksIsolatedtry { fn(args) } catch { report }listener 1 · ranlistener 2 · ranlistener 3 · throws → loggedlistener 4 · still runslistener 5 · still runsemit() returns → the caller is fineisolation stops observer A breaking observer B; it does not mean swallow the error silently
A bare notify loop lets listener 3's throw skip 4 and 5 and blow up the emit call. Wrapping each listener in try/catch isolates the failure.

Wrap each call and one misbehaving observer is contained:

function emit(event, ...args) {
  for (const fn of [...(channels.get(event) ?? [])]) {
    try {
      fn(...args);
    } catch (err) {
      reportError(err); // one bad listener cannot starve the others
    }
  }
}

The trade-off is real and you should feel it. You are now swallowing exceptions, and a listener that throws on every single emit will look perfectly healthy while quietly doing nothing. So do not write catch {}. Route the error to your logger, or emit it on a dedicated "error" channel the way Node does internally, so the failure is loud somewhere even though it no longer takes down the notify loop. Isolation means observer A cannot break observer B. It does not mean hide the bug.

Order, reentrancy, and the sneaky copy

Notice that emit grew a [...] around the set. That spread is not cosmetic. It takes a snapshot of the listeners before the loop starts, and it fixes two problems that a live iteration walks straight into.

Listeners fire in the order they subscribed, which people quietly depend on, so keep it stable. Now imagine a listener that, while running, unsubscribes itself, or subscribes a brand-new listener. Iterate the live Set and you are mutating the thing you are looping over. A newly added listener might get called in the same emit that added it, which is almost never what you want. A removal might skip the next listener. The snapshot makes the rule simple and predictable: everyone who was subscribed the instant emit began gets called exactly once, and anyone who joins or leaves during the notification takes effect on the next emit. Node’s EventEmitter does this same copy internally, for the same reason.

Infinite loops, and the one line that stops them

An observer that reacts to a change by causing a change is a loop waiting to close. The subject notifies the observer, the observer writes back to the subject, the subject notifies again, forever. Two-way form bindings are the classic source: field changes, handler recomputes and writes a value, the write looks like a change, the handler fires again.

subjectvalueobserverreacts + writesnotify(change)writes back → changeruns foreverguard before notifying:if (next === prev) return;
When an observer writes back to its subject, the notification cycle never ends. An equality guard before notifying breaks it.

The cheapest guard kills most of these: only notify when the value actually changed.

function set(next) {
  if (Object.is(next, value)) return; // no change, no notify, no loop
  value = next;
  events.emit("change", value);
}

When the write-back settles on the same value, the second emit never fires and the cycle dies on its own. For the cases that still loop (a handler that keeps producing genuinely different values), you add a reentrancy flag so a notify triggered during a notify is deferred, or you batch changes and flush once. This is precisely why signal libraries schedule their effects instead of running them synchronously on every write. Batching is not a performance nicety there, it is loop insurance.

Synchronous by default, and when to break that

emit is synchronous. The code that called it is blocked until the last listener returns. That is usually what you want: predictable order, and an error surfaces right at the call site. It also means one slow observer stalls everyone, and the thing that fired the event pays for the heaviest handler on the list.

So a listener with real work to do should schedule it and return fast rather than grinding inside the notification:

onCartChange((cart) => queueMicrotask(() => syncToServer(cart))); // return fast, defer the work

The flip side is that a synchronous emit returns before any async listener finishes its awaited work. If you need to know that every observer is done, say you are flushing an event on graceful shutdown, a plain emit will not tell you, because it never waited. You need an emit that collects the returned promises and awaits them:

async function emitAsync(event, ...args) {
  const fns = [...(channels.get(event) ?? [])];
  await Promise.allSettled(fns.map((fn) => fn(...args)));
}

allSettled, not all, so one rejecting listener does not hide whether the others finished. Choosing sync or async notification is a real design decision, not a default to accept blindly. If timing and completion matter, spend a thought on it. The event loop article is the ground truth for why queueMicrotask, await, and “later” mean what they mean.

See it run

Subscribe a few named listeners, fire the change event, and watch who reacts and in what order. Then mount the widget, unmount it without cleanup, and fire again: its listener is still in the set, so it runs as a ghost (in red) against a component that is gone. Flip the cleanup toggle on and unmount properly, and it disappears from the notifications the way it should.

interactiveAn emitter, its listeners, and one deliberate leak

Summary

  • The observer pattern is a subject that holds a list of observers and notifies them all when it changes, so the parts that care react without the subject knowing anything about them. You have used it as addEventListener a thousand times.
  • The mechanism is a Map of Sets and three functions: on, off, emit. Have on return an unsubscribe function, because you will need it.
  • Node’s EventEmitter and the browser’s EventTarget are this pattern, shipped. EventEmitter throws on an unhandled "error" event; EventTarget takes an AbortSignal so one abort() removes many listeners.
  • Observer keeps a visible wire between subject and observer. Pub/sub puts a broker in the middle so publishers and subscribers never reference each other, which scales to many-to-many but hides the wiring. Do not use a global bus just to avoid passing an argument.
  • The listener leak is the failure that pages you. An observer that never unsubscribes keeps its closure, its component, and everything it captured alive forever, and the ghost still fires. Tie every subscription to a lifetime and call its unsubscribe. setMaxListeners and WeakRef are not fixes.
  • A bare notify loop lets one throwing listener starve the rest and blow up the caller. Wrap each listener in try/catch, but report the error, do not swallow it.
  • emit iterates a snapshot so subscribing or unsubscribing mid-notify is predictable, and it is synchronous, so slow work should be scheduled and completion-sensitive fan-out needs an awaiting emit.
  • An observer that writes back to its subject loops forever; guard with if (next === prev) return before you notify.

When to use: whenever one change has many independent reactions and you want the source not to know its consumers. When to avoid: when there is exactly one consumer sitting right there (just call it), or when a readable, traceable call would beat an event fired into a bus nobody can follow.