Observer & pub/sub
Learn observer, pub/sub, event emitters, signals, and observables to let JavaScript app parts react without tight coupling.
- 01Decouple reactionsReplace direct calls from one feature into many features with subscriptions and notifications.
- 02Choose the right event shapeCompare observer subjects, pub/sub buses, DOM events, Node emitters, signals, and observables.
- 03Handle production edgesProtect notification order, isolate errors, unsubscribe reliably, and avoid hidden event flows.
React without tight coupling
Modern JavaScript apps are full of reactions: a cart changes, the header badge updates, analytics records the item, storage persists the cart, and a recommendation widget wakes up. The risky version is one function importing every reaction directly.
Observer-style patterns separate the thing that changed from the things that react. You still need discipline: clear topics, returned cleanup functions, copied subscriber lists, and error isolation keep the pattern production-ready.
Observer lets a subject keep a list of observers and notify them. Publish/subscribe puts a broker between publishers and subscribers. Event emitters, signals, and observables are common JavaScript forms of the same decoupled-reaction idea.
| Tool | Core idea | Tiny example |
|---|---|---|
| Observer | A subject owns the subscriber list and calls observers when its state changes. | A cart subject calls header, analytics, and storage observers in subscribe order. |
| Pub/sub | A broker owns topic lists; publishers name a topic but know nothing about subscribers. | bus.publish("cart:changed", payload) reaches whoever subscribed to that topic. |
| EventTarget | The browser's event system: listeners attach to a target and receive Event or CustomEvent objects. | target.dispatchEvent(new CustomEvent(...)) works with once and AbortSignal. |
| Signals | Fine-grained current values track who reads them and re-run only dependent computations. | total = computed(() => price() * quantity()) updates when either signal changes. |
| Observables | Push streams deliver zero to many future values over time to each subscription. | RxJS streams or WICG EventTarget.prototype.when model events as values over time. |
This lesson builds on callbacks, closures, events, and custom events. It also links to functors & monads for the tiny Observable you have already seen, and to memory management for cleanup leaks.
Start with the pain: a cart that knows too much
PROBLEM FIRSTThe code below is easy to write and hard to maintain. updateCart owns cart data, header rendering, analytics, and storage. Adding a fourth reaction means editing this function again. Testing the cart now requires header, analytics, and storage details.
const cart = []; function updateCart(item) { cart.push(item); renderHeader(cart.length); trackAnalytics(item); saveCart(cart);} function renderHeader(count) { console.log(`header ${count}`);}function trackAnalytics(item) { console.log(`analytics ${item}`);}function saveCart(items) { console.log(`saved ${items.join(",")}`);} updateCart("book");Read it line by line: line 4 changes the cart, line 5 calls header UI, line 6 calls analytics, and line 7 calls persistence. Those are three separate reasons for this function to change. Observer-style code keeps the cart update small and lets reactions subscribe from their own modules.
We want cart.add(item) to mean “the cart changed.” We do not want cart code to know every feature that might care today or six months from now.
Observer: the subject owns a subscriber list
STEP THROUGHIn the observer pattern, a subject exposes subscribe. Each subscriber is a callback. subscribe returns an unsubscribe function, so cleanup is local and explicit. When the subject changes, it notifies observers in the order they subscribed.
A newsletter publisher keeps a list of readers. Readers do not edit the publisher; they subscribe or unsubscribe. When an issue is ready, the publisher sends it to everyone on the current list.
- In real life: Reader signs up
- In JavaScript:
subscribe(observer)adds a callback - In real life: Publisher sends an issue
- In JavaScript:
notify(value)calls each observer - In real life: Reader cancels
- In JavaScript: The returned unsubscribe removes that callback
Where the analogy stops: A newsletter issue is usually delivered later and independently. A JavaScript observer notification is often synchronous: one observer runs, then the next, during the same call stack.
The subject below uses two production safeguards. First, it copies observers with [...observers] before notifying. Second, it catches one observer's error so later observers still run. Step through the example and watch storage unsubscribe itself during the first notification.
Step through the exact notification order. Watch the copied snapshot, the isolated error, and the unsubscribe that affects only the next notification.
script
function createSubject(name) { const observers = new Set(); return { subscribe(observer) { observers.add(observer); return () => observers.delete(observer); }, notify(value) { for (const observer of [...observers]) { try { observer(value); } catch (error) { console.log(`${name} isolated ${error.message}`); } } }, count() { return observers.size; }, };} let stopStorage;cart.subscribe((item) => console.log(`header ${item}`));cart.subscribe(() => { throw new Error("analytics offline"); });stopStorage = cart.subscribe((item) => { console.log(`storage ${item}`); stopStorage();});cart.notify("book");cart.notify("pen");console.log(`subscribers ${cart.count()}`);Notice the exact order: header runs, analytics throws and is isolated, storage still runs, then storage removes itself. The second notification reaches only header and analytics. That behavior depends on the copied list and per-observer try/catch.
Publish/subscribe: put a broker in the middle
TOPICSPub/sub goes one step further than observer. A publisher does not hold the subscriber list. A broker, sometimes called an event bus, owns topic names and handler sets. The publisher emits a topic and payload; subscribers elsewhere choose which topics they want.
A station broadcasts on a frequency. Listeners tune in, but the station does not keep a personal list of everyone listening. A pub/sub broker gives JavaScript code a similar channel model.
- In real life: Station broadcasts on a channel
- In JavaScript: Publisher calls
publish(topic, payload) - In real life: Listeners tune to channels
- In JavaScript: Subscribers register handlers for topics
- In real life: Station does not know each listener
- In JavaScript: Publisher is decoupled from subscribers
Where the analogy stops: A radio broadcast is one-way and usually public. A pub/sub broker may be synchronous, private to an app, and able to return unsubscribe functions.
function createBus() { const topics = new Map(); return { subscribe(topic, handler) { const handlers = topics.get(topic) ?? new Set(); handlers.add(handler); topics.set(topic, handlers); return () => handlers.delete(handler); }, publish(topic, payload) { for (const handler of topics.get(topic) ?? []) { handler(payload); } }, };} const bus = createBus();bus.subscribe("cart:changed", ({ count }) => console.log(`header ${count}`));bus.subscribe("analytics:cart", ({ sku }) => console.log(`analytics ${sku}`));bus.publish("cart:changed", { count: 2, sku: "book" });bus.publish("analytics:cart", { count: 2, sku: "book" });Line 4 subscribes a handler to a topic. Line 10 publishes only to handlers for that exact topic. Naming topics with namespaces such as cart:changed, analytics:cart, or cart:item:removed makes events searchable. Some brokers add wildcards such as cart:*; use them carefully because broad listeners can hide control flow.
Pub/sub is powerful for plugins, analytics, shell-to-microfrontend messages, and cross module events. It is also easy to overuse. Hidden event chains are hard to debug, and a forgotten subscription can become a memory leak.
Event emitters: browser and Node versions
PLATFORM APIsJavaScript platforms already ship event systems. In the browser, EventTarget powers DOM events. You can dispatch your own CustomEvent with a detail payload, just like the custom events lesson shows. Use AbortController when several listeners should be removed together.
const target = new EventTarget();const group = new AbortController(); target.addEventListener("cart:changed", (event) => { console.log(`header ${event.detail.count}`);}, { signal: group.signal }); target.addEventListener("cart:changed", (event) => { console.log(`analytics ${event.detail.sku}`);}, { once: true }); target.dispatchEvent(new CustomEvent("cart:changed", { detail: { count: 1, sku: "book" },}));group.abort();target.dispatchEvent(new CustomEvent("cart:changed", { detail: { count: 2, sku: "pen" },}));The first dispatch logs the header and analytics lines. The analytics listener is once, so it removes itself. Then group.abort() removes the header listener. The second dispatch does not log anything because every listener is gone.
Node's EventEmitter uses different method names: on, once, off, and emit. The error event is special: emitting it with no error listener throws. Node also warns when too many listeners are added to the same event, often pointing at forgotten cleanup.
import { EventEmitter } from "node:events"; const bus = new EventEmitter();function header({ count }) { console.log(`header ${count}`);}function storage({ count }) { console.log(`storage ${count}`);} bus.on("cart:changed", header);bus.once("cart:changed", ({ count }) => console.log(`analytics first ${count}`));bus.on("cart:changed", storage);bus.emit("cart:changed", { count: 1 });bus.off("cart:changed", storage);bus.emit("cart:changed", { count: 2 }); const errors = new EventEmitter();errors.on("error", (error) => console.log(`handled ${error.message}`));errors.emit("error", new Error("offline")); const noisy = new EventEmitter();noisy.setMaxListeners(1);noisy.on("warn", () => {});noisy.on("warn", () => {});console.log(`listeners ${noisy.listenerCount("warn")}`);node:events is a Node built-in, not a browser API. The lesson tests run the snippet in a real child Node process and assert both stdout and the max-listeners warning. The page keeps it read-only.
Signals: current values with fine-grained dependencies
REAL RUNTIMESignals are not just event lists. A signal is a current value. A computed signal derives a current value from other signals. An effect runs when the signals it read last time change. That is why signal systems feel precise: updating quantity should re-run the total computation, not every subscriber in the app.
The tiny runtime below is real JavaScript. It uses a current-effect stack for automatic dependency tracking, cleans old dependencies before re-running an effect, and copies subscribers before notifying them. Production libraries add batching, scheduling, devtools, and edge-case handling, but the core idea is here.
This tiny signals runtime uses an effect stack for automatic dependency tracking. Step through the first computation, then the update that propagates to a computed value and an effect.
script
const effectStack = []; function cleanup(runner) { for (const dependency of runner.dependencies) { dependency.delete(runner); } runner.dependencies.clear();} function effect(work) { const runner = () => { cleanup(runner); effectStack.push(runner); try { work(); } finally { effectStack.pop(); } }; runner.dependencies = new Set(); runner(); return () => cleanup(runner);} function signal(value) { const subscribers = new Set(); const read = () => { const runner = effectStack[effectStack.length - 1]; if (runner) { subscribers.add(runner); runner.dependencies.add(subscribers); } return value; }; read.set = (next) => { if (Object.is(next, value)) return; value = next; for (const runner of [...subscribers]) runner(); }; return read;} function computed(work) { const output = signal(); effect(() => output.set(work())); return output;} const quantity = signal(2);const total = computed(() => price() * quantity());effect(() => console.log(`total ${total()}`));quantity.set(3);You will see this model in Solid, Preact Signals, Angular signals, and Vue refs. The TC39 Signals proposal is currently Stage 1, so the standard JavaScript language does not have built-in signals yet; frameworks provide their own APIs today.
Observables: push streams over time
STREAMSObservables model future values arriving over time: clicks, WebSocket messages, timer ticks, chunks from a stream, or repeated async results. A subscriber receives next values until it unsubscribes or the stream completes. RxJS is the common production library for composing these streams.
The functors & monads lesson built a tiny Observable to explain mapping over a pushed value. This lesson does not rebuild a full observable library; the later event-emitter project will build a full emitter from scratch. Here, focus on the choice: observables are values over time; signals expose a current value now.
console.log(typeof EventTarget.prototype.when);checking
The browser check runs after hydration.
The WICG Observable API brings native observable-style event streams to the web. Current Chrome exposes EventTarget.prototype.when; feature-detect it because browser support and API shape can evolve. If you need portable stream operators today, use RxJS or a framework-provided abstraction.
Choose the right tool in real code
PLAYGROUNDThe best pattern is the smallest one that explains ownership. If one subject owns the state, observer is clear. If unrelated modules communicate by named topics, pub/sub may be worth the traceability cost. If you are in a browser or Node API, prefer the platform emitter. If the problem is derived UI state, signals are usually a better fit than a broad event bus.
| Situation | Fit | Reason |
|---|---|---|
| One object owns state and wants direct subscribers | Observer | The subject can expose subscribe, notify, and a returned unsubscribe. |
| Features communicate across modules without knowing each other | Pub/sub | Topics decouple publishers from subscribers, at the cost of traceability. |
| Browser UI or DOM integration | EventTarget | Use CustomEvent, once, passive, and AbortSignal with platform tooling. |
| Derived values in UI state | Signals | Fine-grained dependency tracking updates only computations that read the value. |
| Cancelable streams over time | Observable | Streams model repeated async values and compose with map/filter/take operators. |
const subscribers = new Set();const store = { count: 0, subscribe(widget) { subscribers.add(widget); return () => subscribers.delete(widget); }, publish(nextCount) { this.count = nextCount; for (const widget of [...subscribers]) { widget(this.count); } },};- Store is ready with header and storage subscribed.
The store will notify 2 subscribers from a copied set when you publish.
`cart.subscribe(renderHeader); cart.notify(item);``bus.publish("cart:changed", payload);``target.dispatchEvent(new CustomEvent("cart:changed"));``emitter.once("ready", handler); emitter.emit("ready");``const total = computed(() => price() * quantity());``buttonClicks.map(toPoint).subscribe(draw);`
Sort each snippet by the pattern it actually uses. Observables share a category with signals here because both are reactive values, but the explanations separate current values from streams.
Common misconceptions
- “Every callback is an observer.” A callback is just a function. Observer adds a subject that stores callbacks and notifies them over time.
- “Observer and pub/sub are the same.” They are related, but pub/sub adds a broker and topic names so publishers do not know subscribers at all.
- “Events are always asynchronous.” Many JavaScript notifications, including
dispatchEventand most simple emitters, run synchronously. - “Catching errors hides bugs.” Swallowing errors hides bugs. Isolating one observer and reporting the error keeps unrelated reactions alive.
- “Signals and observables are interchangeable.” Signals answer “what is the value now?” Observables answer “what values arrive over time?”
- “Max listeners warnings mean raise the limit.” They often mean you forgot to unsubscribe, so investigate cleanup first.
| Risk | What happens | Guardrail |
|---|---|---|
| Hidden control flow | Subscribers run from another file, so a publish call can cause surprising work. | Name topics clearly and keep a searchable event catalog. |
| Forgotten subscriptions | A component unmounts but its listener still holds memory and keeps running. | Return and call unsubscribe functions; use AbortController for groups. |
| Error fan-out | One observer throws while other observers still need the update. | Catch per observer, log/report, and continue with the copied list. |
| Over-broadcasting | One broad event makes every subscriber filter the payload. | Prefer specific topics or derived signals when a single value is what changed. |
| Question | Observer/pub-sub answer | Signal/observable answer |
|---|---|---|
| Who owns subscribers? | Subject owns them for observer; broker owns them for pub/sub. | Each signal owns its dependent effects; each observable subscription owns its teardown. |
| What is delivered? | A payload for an event that happened. | Signals expose a current value; observables push a sequence of values. |
| What is hard to debug? | Hidden event chains and broad wildcard topics. | Stale dependencies, missed cleanup, or stream operators far from the subscription. |
Practice exercises
5 EXERCISESRead the code and type the two console lines separated by a comma.
const observers = [];
function subscribe(observer) {
observers.push(observer);
return () => observers.splice(observers.indexOf(observer), 1);
}
subscribe((value) => console.log("header " + value));
subscribe((value) => console.log("storage " + value));
for (const observer of [...observers]) observer("ready");The copied list contains the header observer first and storage second, so the two lines are header ready and storage ready.
Which single line prints when the bus emits cart:changed?
const topics = new Map();
function on(topic, handler) {
topics.set(topic, [...(topics.get(topic) ?? []), handler]);
}
function emit(topic, value) {
for (const handler of topics.get(topic) ?? []) handler(value);
}
on("cart:changed", (count) => console.log("header " + count));
on("analytics:cart", (count) => console.log("analytics " + count));
emit("cart:changed", 2);The publish uses cart:changed, so only the header subscriber runs and prints header 2.
once listenerPredict the text printed by the two dispatches.
const target = new EventTarget();
target.addEventListener("save", () => console.log("saved once"), { once: true });
target.dispatchEvent(new Event("save"));
target.dispatchEvent(new Event("save"));The first dispatch logs saved once. The listener removes itself, so the second dispatch adds no new output.
Type the number printed by the subscriber.
let count = 1;
const subscribers = new Set();
const read = () => count;
read.set = (next) => {
count = next;
for (const subscriber of [...subscribers]) subscriber();
};
subscribers.add(() => console.log(read() * 2));
read.set(3);The update changes count to 3, then the subscriber logs 3 * 2, so the answer is 6.
A checkout page needs total to update when price or quantity changes, and only UI that reads total should re-render. Which pattern fits best?
const total = computed(() => price() * quantity());A signal fits because total is a current derived value that should update only its dependents.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence best describes the observer pattern?
Choose an answer to see the explanation.
Question 2 of 7What does the copied-list observer loop print?
Read the code, then predictconst listeners = []; listeners.push(() => console.log("A")); let stopB = () => {}; stopB = () => listeners.splice(1, 1); listeners.push(() => { console.log("B"); stopB(); }); listeners.push(() => console.log("C")); for (const listener of [...listeners]) listener(); console.log(listeners.length);Choose an answer to see the explanation.
Question 3 of 7What prints from the pub/sub topic example?
Read the code, then predictconst topics = new Map(); function on(topic, handler) { topics.set(topic, [...(topics.get(topic) ?? []), handler]); } function emit(topic, value) { for (const handler of topics.get(topic) ?? []) handler(value); } on("cart:changed", (count) => console.log("header " + count)); on("analytics:cart", (count) => console.log("analytics " + count)); emit("cart:changed", 2);Choose an answer to see the explanation.
Question 4 of 7What does
{ once: true }do here?Read the code, then predictconst target = new EventTarget(); target.addEventListener("save", () => console.log("saved once"), { once: true }); target.dispatchEvent(new Event("save")); target.dispatchEvent(new Event("save"));Choose an answer to see the explanation.
Question 5 of 7Why is Node's
errorevent special onEventEmitter?Choose an answer to see the explanation.
Question 6 of 7What does the tiny signal exercise print?
Read the code, then predictlet count = 1; const subscribers = new Set(); const read = () => count; read.set = (next) => { count = next; for (const subscriber of [...subscribers]) subscriber(); }; subscribers.add(() => console.log(read() * 2)); read.set(3);Choose an answer to see the explanation.
Question 7 of 7Which contrast between signals and observables is most useful?
Choose an answer to see the explanation.
Key takeaways
- Observer keeps a subject and its observer list together.
- Pub/sub adds a broker and topics so publishers do not know subscribers.
- Copy subscriber lists before notifying, return unsubscribe functions, and isolate errors.
- Use browser
EventTargetand NodeEventEmitterwhen the platform already provides the event system. - Signals model current values; observables model pushed values over time.
Remember the one-liner.
Decouple reactions by choosing who owns the subscriber list, how messages are routed, and how cleanup is guaranteed.
Up next: structural and behavioral patterns, then a later project that builds a complete event emitter from scratch.