Implementing the Observer Pattern in JavaScript: Loosely Coupled Event Systems
AI generated
JS
() =>
JavaScript · Observer Pattern · Design Patterns · Framework agnostic
Implementing the Observer Pattern in JavaScript
Loosely Coupled Event Systems

The Observer Pattern decouples a state holder from the pieces of code that need to react to its changes, with no direct references between them at all. From a minimal subject class through the native EventTarget interface to WeakRef-based cleanup, this article shows how to implement the Observer Pattern robustly in plain JavaScript.

18 min read Subject/Observer · EventTarget · EventEmitter · WeakRef Framework independent

1. What the Observer Pattern Really Solves

The Observer Pattern describes a one-to-many relationship between a subject that holds state and any number of observers that want to be informed about changes to that state, without subject and observer needing direct references to each other. The subject only knows a list of registered callback functions, not the concrete identity or implementation of the observers, a defining trait of loose coupling.

This decoupling solves a recurring architectural problem: without the Observer Pattern, every piece of code that triggers a state change would have to explicitly know and directly call all dependent pieces of code, leading to a heavily entangled dependency graph. With the Observer Pattern, every interested piece of code registers itself independently, the subject needs to know nothing about its observers other than the fact they want to be notified.

This article implements the Observer Pattern in several variants: first from scratch with a minimal subject class, then through the browser's native EventTarget interface, as a rebuilt Node.js-style event emitter, and finally with asynchronous observers and WeakRef-based memory protection.

2. A Minimal Observer Pattern From Scratch

The simplest implementation of the Observer Pattern consists of a subject class with three methods: subscribe() registers an observer in an internal list, unsubscribe() removes it again, and notify() calls every registered observer with the current data. These three methods form the complete vocabulary of the pattern, regardless of how complex the concrete application later becomes.

An important detail in the implementation: subscribe() should return an unsubscribe function instead of forcing the caller to keep track of the original callback reference separately. This pattern, known from RxJS and many modern event libraries, makes cleanup code considerably more robust, since the caller only needs to call the returned function, without knowing the internal data structure of the Observer Pattern.


// Minimal Observer Pattern: Subject with subscribe/unsubscribe/notify
class Subject {
  #observers = new Set();

  subscribe(observerFn) {
    this.#observers.add(observerFn);
    // Return an unsubscribe function, the caller never touches the Set directly
    return () => this.#observers.delete(observerFn);
  }

  notify(data) {
    for (const observerFn of this.#observers) {
      observerFn(data);
    }
  }
}

const priceSubject = new Subject();

const unsubscribe = priceSubject.subscribe((price) => {
  console.log(`Price updated: ${price}`);
});

priceSubject.notify(19.99); // "Price updated: 19.99"
unsubscribe(); // stops receiving further notifications

3. EventTarget: the Browser's Native Observer Pattern

Instead of writing a custom subject class, any class can extend the native EventTarget interface and thereby directly benefit from the browser's built-in Observer Pattern. addEventListener(), removeEventListener() and dispatchEvent() take on exactly the role of subscribe(), unsubscribe() and notify(), with the advantage that these APIs are already familiar to every JavaScript developer and inspectable in dev tools.

EventTarget also supports AbortSignal as an elegant way to remove several listeners at once: instead of manually unregistering each individual listener with removeEventListener(), a single AbortController.abort() call cancels every listener bound to it simultaneously. This built-in support makes EventTarget the most pragmatic choice for the Observer Pattern whenever code runs in a browser environment anyway.


// EventTarget as the browser's native Observer Pattern implementation
class PriceTracker extends EventTarget {
  #price = 0;

  set price(value) {
    this.#price = value;
    this.dispatchEvent(new CustomEvent('price-change', { detail: value }));
  }

  get price() {
    return this.#price;
  }
}

const tracker = new PriceTracker();
const controller = new AbortController();

tracker.addEventListener('price-change', (event) => {
  console.log(`New price: ${event.detail}`);
}, { signal: controller.signal });

tracker.price = 29.99; // triggers the listener

controller.abort(); // removes all listeners bound to this signal at once

4. Rebuilding the EventEmitter Pattern in Vanilla JavaScript

Node.js has shaped its own take on the Observer Pattern for years, the EventEmitter, with named events instead of a single generic notify call. The key difference from the simple subject class: an EventEmitter manages a map of event names to listener arrays, so on('save', handler) and on('delete', handler) work independently of each other, without every observer having to filter all events.

A hand-built EventEmitter in plain JavaScript works anywhere outside Node.js where EventTarget is off the table for compatibility reasons, for example in worker threads with a limited DOM API or in isolated test environments. The pattern remains exactly the same Observer Pattern, only the data structure changes from a single set to a map of several sets, organized by event name.


// Node.js-style EventEmitter rebuilt in plain JavaScript
class EventEmitter {
  #listeners = new Map();

  on(eventName, handler) {
    if (!this.#listeners.has(eventName)) {
      this.#listeners.set(eventName, new Set());
    }
    this.#listeners.get(eventName).add(handler);
    return () => this.#listeners.get(eventName)?.delete(handler);
  }

  emit(eventName, payload) {
    this.#listeners.get(eventName)?.forEach((handler) => handler(payload));
  }
}

const orderEvents = new EventEmitter();

orderEvents.on('save', (order) => console.log(`Saved order ${order.id}`));
orderEvents.on('delete', (order) => console.log(`Deleted order ${order.id}`));

orderEvents.emit('save', { id: 42 }); // only "save" listeners run

5. Prioritized and Filtered Notifications

In larger applications, a simple list of observers is often not enough, because some observers must run before others, for example a validation observer before a logging observer. An extension of the Observer Pattern stores each observer together with a priority and re-sorts the internal list on every registration, so notify() calls observers in the desired order instead of registration order.

Filtered notifications are another practical extension: instead of calling every observer on every change, the subject checks an optional filter predicate provided by the observer at registration time before calling it. The Observer Pattern stays structurally unchanged here, only the notify() method gets extra filtering logic before the actual call, which avoids unnecessary notifications without complicating the registration API.

6. Asynchronous Observers With Promises and Async Iterables

Synchronous observers are not enough once an observer itself needs to run an asynchronous operation, for example writing to a database after a state change. An asynchronous variant of the Observer Pattern lets notify() collect all observer promises and wait for their completion with Promise.allSettled(), instead of swallowing observer errors uncontrollably or making the subject blocking.

An elegant alternative for streaming scenarios is an async iterable as the observer interface: instead of registering callback functions, the consumer registers nothing and simply iterates with for await...of over a queue the subject fills internally. This pattern connects the Observer Pattern with JavaScript's iterator protocol world and is especially suited for data streams with backpressure requirements.


// Async observer: waiting for all observer promises, error-isolated
class AsyncSubject {
  #observers = new Set();

  subscribe(observerFn) {
    this.#observers.add(observerFn);
    return () => this.#observers.delete(observerFn);
  }

  async notify(data) {
    const results = await Promise.allSettled(
      [...this.#observers].map((fn) => fn(data))
    );
    // Isolate failures: one rejected observer must not break the others
    results
      .filter((r) => r.status === 'rejected')
      .forEach((r) => console.error('Observer failed:', r.reason));
  }
}

const saveSubject = new AsyncSubject();
saveSubject.subscribe(async (order) => {
  await fetch('/api/audit-log', { method: 'POST', body: JSON.stringify(order) });
});

await saveSubject.notify({ id: 42 });

7. Memory Management: WeakRef Against Observer Leaks

A common memory leak in every Observer Pattern implementation arises when observers never unsubscribe, for example because a UI component gets removed without calling unsubscribe(). The long-lived subject then holds a strong reference to the observer callback and, indirectly, to every object closed over inside it, even when the actual component is long gone from view.

WeakRef combined with FinalizationRegistry offers protection against exactly this leak: the subject stores observers as WeakRef instances, so garbage collection can still reclaim the observer once no other strong reference exists. On notify(), the subject checks weakRef.deref(), automatically removes already collected references from the list, and calls only observers that are still alive. This pattern is no substitute for explicit unsubscribe(), but a sensible safety net for observer lists with uncertain lifetimes.


// WeakRef-based observers as a safety net against forgotten unsubscribe calls
class WeakSubject {
  #observers = new Set(); // holds WeakRef instances

  subscribe(observerFn) {
    const ref = new WeakRef(observerFn);
    this.#observers.add(ref);
    return () => this.#observers.delete(ref);
  }

  notify(data) {
    for (const ref of this.#observers) {
      const observerFn = ref.deref();
      if (observerFn) {
        observerFn(data);
      } else {
        this.#observers.delete(ref); // garbage collected, clean up the ref
      }
    }
  }
}

8. The Observer Pattern Versus Pub/Sub and RxJS

The Observer Pattern and Pub/Sub are often used interchangeably, but they differ structurally in one detail: in the classic Observer Pattern, the subject knows its observers directly, whereas in true Pub/Sub a mediating message broker sits between publisher and subscriber, so publisher and subscriber never know each other directly, not even through a shared subject instance. This extra layer of indirection allows publisher and subscriber to run in completely separate modules or even processes.

RxJS observables extend the Observer Pattern with a rich operator library that transforms, combines and filters streams of values, such as debounceTime, merge or retry. This power comes at the cost of a noticeably steeper learning curve and additional bundle size, which is why a simple, hand-built Observer Pattern remains the more pragmatic choice for many use cases, without depending on a full reactive extensions library.

9. Observer Implementations Compared

The choice among the presented variants of the Observer Pattern depends on the context: browser code usually benefits from EventTarget, complex asynchronous data streams from RxJS, and simple, framework-independent cases from a hand-built subject class.

Implementation Dependency Cleanup Best Fit
Custom subject class None Manual via unsubscribe function Framework-independent libraries
EventTarget Native browser AbortController bundled Browser code, DOM-adjacent components
EventEmitter rebuild None Manual per event name Node.js-style APIs outside Node.js
RxJS Observable External library Subscription.unsubscribe() Complex data streams with operators

In practice, a hand-built Observer Pattern implementation pays off especially when a library needs to stay framework independent without carrying the bundle size of a full reactive extensions library. The building blocks presented here, from the minimal subject class to WeakRef-based cleanup, can be combined as needed, depending on the requirements for memory management and asynchronous behavior.

Mironsoft

Event architecture, decoupling and memory management on the frontend

Event systems without leaks or tight coupling?

We analyze existing event architectures, replace tightly coupled code with a cleanly implemented Observer Pattern, and secure cleanup with AbortController or WeakRef.

Event audit

Analysis of existing listener registrations for memory leaks

Observer refactoring

Turning tightly coupled callback chains into a clean Observer Pattern

Cleanup strategy

AbortController integration and WeakRef-based safety nets

10. Summary

The Observer Pattern decouples state holders from the pieces of code that react to changes through a simple registration and notification interface. A minimal subject class with subscribe(), unsubscribe() and notify() suffices for most use cases, while EventTarget provides the same functionality natively in the browser, including AbortController-bundled cleanup.

Advanced variants like asynchronous observers with Promise.allSettled() and WeakRef-based references against memory leaks extend the base pattern for production-ready requirements. Compared to Pub/Sub through a message broker or RxJS observables, a hand-built Observer Pattern remains the lightest-weight option whenever the power of a full reactive extensions library is not needed.

Implementing the Observer Pattern in JavaScript — Key Takeaways

Core Pattern

Subject with subscribe(), unsubscribe() and notify(), returning an unsubscribe function.

Native Alternative

EventTarget with AbortController for bundled cleanup, ideal in the browser.

Async & Memory

Promise.allSettled() for async observers, WeakRef as a safety net against leaks.

Distinctions

Pub/Sub adds a message broker, RxJS extends with an operator library.

11. FAQ: Implementing the Observer Pattern in JavaScript

1What is the Observer Pattern?
A one-to-many relationship between subject and observers without direct references to each other.
2Why return an unsubscribe function?
Simplifies cleanup without knowledge of the internal data structure.
3Is EventTarget better than a custom class?
Usually yes in the browser, due to native AbortSignal integration.
4Observer Pattern vs. EventEmitter?
EventEmitter is a concrete form with named events instead of a generic notify.
5Errors in async observers?
Promise.allSettled() isolates one observer's failure from the others.
6What is WeakRef for?
A safety net against memory leaks when unsubscribe is forgotten.
7Observer Pattern vs. Pub/Sub?
Pub/Sub adds a mediating message broker.
8When to use RxJS instead?
For complex streams needing operators like debounceTime or retry.
9Can observers be prioritized?
Yes, via a stored priority and a re-sorted internal list.
10Does it work outside the browser?
Yes, a custom subject class or EventEmitter rebuild work anywhere.