cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Observer & pub/sub

Learn observer, pub/sub, event emitters, signals, and observables to let JavaScript app parts react without tight coupling.

By the end, you can
  • 01
    Decouple reactionsReplace direct calls from one feature into many features with subscriptions and notifications.
  • 02
    Choose the right event shapeCompare observer subjects, pub/sub buses, DOM events, Node emitters, signals, and observables.
  • 03
    Handle 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.

Plain definition

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.

Five ways to decouple reactions
ToolCore ideaTiny example
ObserverA 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/subA broker owns topic lists; publishers name a topic but know nothing about subscribers.bus.publish("cart:changed", payload) reaches whoever subscribed to that topic.
EventTargetThe browser's event system: listeners attach to a target and receive Event or CustomEvent objects.target.dispatchEvent(new CustomEvent(...)) works with once and AbortSignal.
SignalsFine-grained current values track who reads them and re-run only dependent computations.total = computed(() => price() * quantity()) updates when either signal changes.
ObservablesPush 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 FIRST

The 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.

Tightly coupled cart updatePop out in the code editor (opens in a new tab)JavaScript
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.

The design goal

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 THROUGH

In 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.

Real-life analogyNewsletter subscriptions

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.

Observer notification order
Step 0 of 15Ready
Your turn: follow the blue line

Step through the exact notification order. Watch the copied snapshot, the isolated error, and the unsubscribe that affects only the next notification.

Running in
  1. script
Next: line 23
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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()}`);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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

TOPICS

Pub/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.

Real-life analogyRadio station topics

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.

Tiny topic brokerPop out in the code editor (opens in a new tab)JavaScript
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 trade-offs

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 APIs

JavaScript 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.

Browser EventTarget with CustomEvent and AbortSignalPop out in the code editor (opens in a new tab)JavaScript
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.

Node EventEmitter: on, once, off, emit, error, and listener warningsJavaScript
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")}`);
Why no browser run button for the Node example?

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 RUNTIME

Signals 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.

Tiny signals runtime
Step 0 of 10Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 49
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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

STREAMS

Observables 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.

Native Observable feature check
Observable API feature checkPop out in the code editor (opens in a new tab)JavaScript
console.log(typeof EventTarget.prototype.when);
Resultfeature detect

checking

Try it yourself
Run in this browser

The browser check runs after hydration.

The standards proposal is still worth feature-detecting. RxJS remains the portable library choice when you need observables across browsers.

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

PLAYGROUND

The 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.

Choosing the communication shape
SituationFitReason
One object owns state and wants direct subscribersObserverThe subject can expose subscribe, notify, and a returned unsubscribe.
Features communicate across modules without knowing each otherPub/subTopics decouple publishers from subscribers, at the cost of traceability.
Browser UI or DOM integrationEventTargetUse CustomEvent, once, passive, and AbortSignal with platform tooling.
Derived values in UI stateSignalsFine-grained dependency tracking updates only computations that read the value.
Cancelable streams over timeObservableStreams model repeated async values and compose with map/filter/take operators.
Store to subscribers diagram
Store with subscriber setPop out in the code editor (opens in a new tab)JavaScript
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);    }  },};
Live diagramcart count 1
Storecount = 1
Header badgesubscribed
Analyticsunsubscribed
Storagesubscribed
Recent events
  1. Store is ready with header and storage subscribed.
Try it yourself

The store will notify 2 subscribers from a copied set when you publish.

This is a conceptual diagram backed by the visible store code. Toggle widgets, publish, and notice how cleanup changes future notifications.
Observer, pub/sub, emitter, or signal?
  • `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);`
Try it yourself
0 of 6 correct

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 dispatchEvent and 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.
Production risks and safeguards
RiskWhat happensGuardrail
Hidden control flowSubscribers run from another file, so a publish call can cause surprising work.Name topics clearly and keep a searchable event catalog.
Forgotten subscriptionsA component unmounts but its listener still holds memory and keeps running.Return and call unsubscribe functions; use AbortController for groups.
Error fan-outOne observer throws while other observers still need the update.Catch per observer, log/report, and continue with the copied list.
Over-broadcastingOne broad event makes every subscriber filter the payload.Prefer specific topics or derived signals when a single value is what changed.
Fast practical comparison
QuestionObserver/pub-sub answerSignal/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 EXERCISES
Exercise 1 · Warm-upPredict observer order

Read the code and type the two console lines separated by a comma.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFollow one pub/sub topic

    Which single line prints when the bus emits cart:changed?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead an EventTarget once listener

      Predict the text printed by the two dispatches.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const target = new EventTarget();
      target.addEventListener("save", () => console.log("saved once"), { once: true });
      target.dispatchEvent(new Event("save"));
      target.dispatchEvent(new Event("save"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict a tiny signal update

        Type the number printed by the subscriber.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose a pattern for derived UI state

          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?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence best describes the observer pattern?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does the copied-list observer loop print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            3. Question 3 of 7What prints from the pub/sub topic example?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            4. Question 4 of 7What does { once: true } do here?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            5. Question 5 of 7Why is Node's error event special on EventEmitter?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does the tiny signal exercise print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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);

              Choose an answer to see the explanation.

            7. 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 EventTarget and Node EventEmitter when 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.

            CompleteFrontend Clear concepts. Working examples.