Observer is the pattern behind every "notify me when something happens" feature: a follower seeing a new post, a dashboard updating when an order is paid, an email going out after signup. One object, the subject, keeps a list of observers and calls them when its state changes. The subject does not know what the observers do, only that they want to hear about it.

Difficulty: ★★★☆☆. Popularity: ★★★★★. It is arguably the most used pattern in JavaScript, because the DOM, Node.js streams and every UI framework are built on it.

The problem: a function that knows everybody

Here is what happens when an order is paid, written directly:

async function markOrderPaid(orderId: string) {
  const order = await orders.update(orderId, { status: "paid" });

  await mailer.send(order.customerEmail, "Thanks for your order", renderReceipt(order));
  await inventory.reserve(order.items);
  await analytics.track("order_paid", { orderId, total: order.total });
  await loyalty.addPoints(order.customerId, Math.floor(order.total / 10_000));
  await slack.post("#sales", `New order ${orderId}: ${order.total}đ`);

  return order;
}

It works on day one. The trouble is what it depends on:

  • The order module imports five other modules. Mail, inventory, analytics, loyalty and Slack all become dependencies of the core payment flow.
  • Every new reaction edits this function. The loyalty team adding a feature means touching payment code and getting a review from the payments team.
  • One failure fails everything. If Slack is down, markOrderPaid throws after the order is already marked paid, and the customer sees an error for a payment that went through.
  • Everything is sequential. The customer waits for an analytics call and a Slack message before seeing the confirmation page.

What we want is for the order module to announce one fact, "order paid", and for whoever cares to react.

The pattern: subject and observers

The classic shape is a subject with subscribe, unsubscribe and notify. In TypeScript, it is worth making it typed by event name from the start:

type Listener<T> = (payload: T) => void | Promise<void>;

export class EventBus<Events extends Record<string, unknown>> {
  private listeners = new Map<keyof Events, Set<Listener<never>>>();

  on<K extends keyof Events>(event: K, listener: Listener<Events[K]>): () => void {
    const set = this.listeners.get(event) ?? new Set();
    set.add(listener as Listener<never>);
    this.listeners.set(event, set);
    return () => set.delete(listener as Listener<never>); // unsubscribe
  }

  async emit<K extends keyof Events>(event: K, payload: Events[K]): Promise<void> {
    const set = this.listeners.get(event);
    if (!set) return;
    const results = await Promise.allSettled(
      [...set].map(async (listener) => (listener as Listener<Events[K]>)(payload)),
    );
    for (const result of results) {
      if (result.status !== "rejected") continue;
      const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
      console.error(`[${String(event)}] listener failed: ${reason}`);
    }
  }
}

Three decisions are baked in here, and each one fixes a problem from the naive version:

  • on returns an unsubscribe function, so observers can leave and you do not leak listeners.
  • emit runs listeners concurrently with Promise.allSettled, so one slow or failing observer cannot block or break the others.
  • The Events type maps event names to payloads, so emit("order.paid", { wrong: true }) fails to compile.

Now the order module only knows about the bus:

type Order = { id: string; customerId: string; customerEmail: string; total: number };

type DomainEvents = {
  "order.paid": Order;
  "user.signed-up": { userId: string; email: string };
};

export const events = new EventBus<DomainEvents>();

export async function markOrderPaid(order: Order) {
  // ...persist the status change first...
  await events.emit("order.paid", order);
  return order;
}

And each reaction lives with the team that owns it:

// mail/subscribers.ts
events.on("order.paid", async (order) => {
  console.log(`mail: receipt to ${order.customerEmail}`);
});

// loyalty/subscribers.ts
events.on("order.paid", async (order) => {
  console.log(`loyalty: +${Math.floor(order.total / 10_000)} points for ${order.customerId}`);
});

// notifications/subscribers.ts
events.on("order.paid", async () => {
  throw new Error("Slack is down");
});

await markOrderPaid({ id: "A-1001", customerId: "c-7", customerEmail: "an@example.com", total: 450_000 });
// mail: receipt to an@example.com
// loyalty: +45 points for c-7
// [order.paid] listener failed: Slack is down

The Slack failure is logged, the other observers still ran, and markOrderPaid returned normally. Adding a new reaction is a new events.on(...) in a new file; the order module does not change.

Node.js already has one

For in-process events, Node's built-in EventEmitter is the same pattern:

import { EventEmitter, once } from "node:events";

const orders = new EventEmitter();
orders.on("paid", (order: Order) => console.log("paid", order.id));

// `once` turns the next occurrence of an event into a promise
const nextPaid = once(orders, "paid");
orders.emit("paid", { id: "A-1002", customerId: "c-8", customerEmail: "b@example.com", total: 99_000 });
const [order] = await nextPaid;
console.log("awaited", order.id);
// paid A-1002
// awaited A-1002

Know its sharp edges before relying on it: listeners run synchronously in registration order, a throwing listener throws out of emit, an "error" event with no listener crashes the process, and it warns when more than 10 listeners are attached to one event (often a sign of a leak). The typed bus above exists to change exactly those defaults.

In the browser, EventTarget and CustomEvent play the same role, and every framework's state store (Redux subscribe, signals, RxJS observables) is Observer with more machinery around it.

From Observer to Pub/Sub

Observer is in-process: the subject holds direct references to its observers, and everything lives and dies with one Node.js process. Once events must survive a crash, reach another service, or be processed by many workers, you move to publish/subscribe with a broker in the middle:

Observer Pub/Sub
Who knows whom Subject holds observer references Publisher and subscriber only know the broker and a topic
Where it runs One process Across processes and services
Delivery Function call, lost if the process dies Persisted, retried (Kafka, RabbitMQ, SQS, Redis Streams)
Typical use UI updates, in-module domain events Fan-out to other services, background work, integration

The news feed in chapter 15 is this step: "a user posted" is an event, and fan-out workers are subscribers that deliver it to followers' feeds.

Moving to a broker brings its own rule, the outbox pattern: write the event to an outbox table in the same database transaction as the state change, and let a relay publish it. Otherwise a crash between "order saved" and "event published" leaves the two out of sync.

When to use it, and when not to

Use it when:

  • One change must trigger several reactions owned by different modules or teams.
  • The subject should not depend on the observers (core domain code should not import Slack).
  • Observers come and go at runtime (UI components, websocket clients).

Skip it when:

  • There is exactly one reaction and it is part of the same operation. A direct call is clearer.
  • The caller needs the result of the reaction to continue. Events are for "this happened", not for "please compute this and give it back".

Common pitfalls

  1. Forgetting to unsubscribe. Components and request handlers that subscribe without unsubscribing keep growing the listener list and keep dead objects alive. Always keep the returned unsubscribe function.
  2. Hidden control flow. When ten observers react to one event, nobody can read the whole flow in one place. Keep a list of events and their subscribers, and name events as past-tense facts (order.paid, not sendReceipt).
  3. Observers that emit events that trigger observers. Cascades get hard to reason about and can loop. If an observer needs to start a long workflow, make that explicit.
  4. Treating in-process events as durable. If the process restarts between emit and the observer finishing, the work is gone. Anything that must happen (charging, emails with legal content) needs a queue or the outbox pattern.
  5. Ordering assumptions. Observers should not depend on running before or after each other. If they do, they are really one step.

Exercises

  1. Add once(event, listener) to EventBus, which unsubscribes itself after the first call. Write a test that proves it runs exactly once.
  2. Add a timeout to emit so a listener that takes longer than 2 seconds is reported as failed without blocking the others.
  3. Replace the in-memory bus for order.paid with a Redis Stream or a RabbitMQ queue, and make the loyalty subscriber idempotent so a redelivered event does not add points twice.
  4. Take the naive markOrderPaid at the top of this chapter and list which reactions must be durable and which are fine to lose. Which ones would you move to a broker first?