Skip to main content

Observer pattern

Observer pattern - a behavioral design pattern where a subject keeps a list of observers and notifies them automatically when its state changes.

Theory

TL;DR

  • YouTube channel analogy: subscribers get push notifications on new videos; the channel doesn't track their emails directly
  • Subject broadcasts changes to all listeners without knowing their types or count
  • Use it when 2+ objects need state sync without hard references to each other
  • Core mechanic: subscribe(), notify(), unsubscribe()
  • Node.js EventEmitter and React useEffect are both Observer implementations

Quick example

javascript
class Subject { constructor() { this.observers = []; } subscribe(obs) { this.observers.push(obs); } unsubscribe(obs) { this.observers = this.observers.filter(o => o !== obs); } notify(data) { this.observers.forEach(obs => obs.update(data)); } } class Logger { update(data) { console.log(`Received: ${data}`); } } const subject = new Subject(); subject.subscribe(new Logger()); subject.notify('user logged in'); // Received: user logged in

Subject holds a plain array of observer references. notify() iterates and calls update() on each one. No direct import of Logger inside Subject. That's the point.

Key difference

Direct method calls hard-wire the caller to specific objects. If your service calls logger.log() directly, you can't swap logger without touching that service. Observer inverts this: the subject doesn't know who's listening. You add or remove observers at runtime, and the subject keeps working unchanged. This is what loose coupling means in practice.

When to use

  • One object changes, many react: user logs in, you need to update the UI, refresh a cache, and send an analytics event. One notify() call instead of three manual ones.
  • Observer count varies at runtime: plugins, feature toggles, dynamic dashboards. A hardcoded list of handlers breaks the moment requirements change.
  • Loose sync between modules: MVC views observing a model, Redux store notifying connected components.
  • Skip it when a simple callback covers the case, or when you'll always have exactly one listener that never changes.

How notify works internally

In JavaScript, notify() iterates the observers array synchronously by default. Each obs.update() call goes through the prototype chain via dynamic dispatch. Node.js EventEmitter builds on this and routes async work through libuv so emits don't block the event loop. React's useEffect takes the same concept further: the component subscribes to a dependency array, and React schedules the effect after the render commits to the DOM. Both are the same idea at different layers of abstraction.

Common mistakes

Forgetting unsubscribe causes memory leaks. The observer stays in the subject's array, and the garbage collector can't reclaim it. In long-running Node servers this accumulates to hundreds of megabytes.

javascript
// Wrong: observer held in memory forever subject.subscribe(obs); // Fix: always clean up subject.subscribe(obs); const cleanup = () => subject.unsubscribe(obs); // In React: return cleanup from useEffect

Synchronous notify in recursive chains causes stack overflow. If an observer's update() triggers another notify(), you get recursion. V8 caps the call stack at around 10k frames.

javascript
// Wrong: subject.notify() -> obs.update() -> subject.notify() -> crash // Fix: break the chain with async scheduling notify(data) { setImmediate(() => this.observers.forEach(obs => obs.update(data))); }

Passing mutable objects creates shared-state bugs. Two observers get a reference to the same object. Both mutate it. State becomes unpredictable. React strict mode surfaces this quickly.

javascript
// Wrong: both observers share one reference subject.notify({ users: usersArray }); // Fix: pass a snapshot subject.notify({ users: [...usersArray] });

No error handling crashes the whole broadcast. One observer throws, the rest never receive the notification. This has taken down production data pipelines.

javascript
notify(data) { this.observers.forEach(obs => { try { obs.update(data); } catch (err) { console.error('Observer error:', err); } }); }

Real-world usage

  • React: useEffect dependency array observes prop/state changes; cleanup function = unsubscribe
  • Node.js EventEmitter: req.on('data', handler) observes stream chunks in the core http module
  • Redux: store.subscribe() notifies connected components on dispatch; RTK Query uses the same idea for API state
  • RxJS: Observable.subscribe() is Observer plus cancellation via Subscription
  • Vue reactivity: computed properties observe reactive data and recalculate when it changes
  • vs Pub-Sub: Observer uses direct references (subject holds observer refs), Pub-Sub adds a broker for decoupled topic-based routing

Follow-up questions

Q: How do you implement Observer without classes in 10 lines?
A: Use closures. const createSubject = () => { let obs = []; return { subscribe: f => obs.push(f), unsubscribe: f => { obs = obs.filter(o => o !== f); }, notify: d => obs.forEach(f => f(d)) }; };. Same contract, zero class overhead.

Q: What is the difference between Observer and Pub-Sub?
A: Observer is direct: the subject holds actual references to its observers. Pub-Sub adds a broker in the middle. Subscribers register for a topic, and the publisher never knows who's listening. Redis pub-sub and MQTT work this way. Choose Observer for in-process state sync, Pub-Sub for distributed systems.

Q: How does React's useEffect differ from classic Observer?
A: Classic Observer notifies immediately and synchronously. React batches changes and runs effects after the render commits to the DOM. The cleanup function maps to unsubscribe. The deps array defines what the component is observing.

Q: How do you handle EventEmitter leaks in Node.js clusters?
A: Per-fork listeners accumulate if you skip cleanup. Use emitter.once() for one-shot events so they auto-remove. Register process.on('exit', cleanup) for persistent listeners. Call removeAllListeners() on teardown in test environments. Juniors usually miss the once() pattern; seniors mention it immediately.

Examples

Basic Observer in TypeScript

typescript
interface IObserver { update(subject: ISubject): void; } interface ISubject { attach(observer: IObserver): void; detach(observer: IObserver): void; notify(): void; } class UserStore implements ISubject { private observers: IObserver[] = []; private loggedIn: boolean = false; attach(observer: IObserver): void { this.observers.push(observer); } detach(observer: IObserver): void { this.observers = this.observers.filter(o => o !== observer); } notify(): void { for (const observer of this.observers) { try { observer.update(this); } catch (err) { console.error('Observer error:', err); } } } login(): void { this.loggedIn = true; console.log('UserStore: user logged in'); this.notify(); } isLoggedIn(): boolean { return this.loggedIn; } } class NavbarObserver implements IObserver { update(subject: ISubject): void { if (subject instanceof UserStore && subject.isLoggedIn()) { console.log('Navbar: showing user menu'); } } } class AnalyticsObserver implements IObserver { update(subject: ISubject): void { if (subject instanceof UserStore && subject.isLoggedIn()) { console.log('Analytics: tracking login event'); } } } const store = new UserStore(); store.attach(new NavbarObserver()); store.attach(new AnalyticsObserver()); store.login(); // UserStore: user logged in // Navbar: showing user menu // Analytics: tracking login event

UserStore calls notify() after its state changes. Both observers react independently and know nothing about each other. Adding a third observer (a cache invalidator, for example) requires zero changes to UserStore.

Node.js EventEmitter with proper cleanup

Node's EventEmitter is the standard Observer implementation in the ecosystem. The memory leak trap is easy to miss on a busy server.

javascript
const { EventEmitter } = require('events'); const dataStream = new EventEmitter(); function startListening() { const handleData = (data) => { console.log('Received chunk:', data); }; dataStream.on('data', handleData); // Always return a cleanup function return () => dataStream.off('data', handleData); } const stop = startListening(); dataStream.emit('data', 'chunk 1'); // Received chunk: chunk 1 dataStream.emit('data', 'chunk 2'); // Received chunk: chunk 2 stop(); // Remove the listener dataStream.emit('data', 'chunk 3'); // Nothing printed

Without calling stop(), handleData stays in the listener array permanently. In a server handling thousands of requests this accumulates fast. I've seen this pattern drain 200MB of heap in a single production session.

React useEffect as Observer

React's dependency array is Observer logic built into the framework. The component subscribes to changes in specific values, and React handles the notify/unsubscribe cycle.

jsx
import { useEffect, useState } from 'react'; function PriceDisplay({ productId }) { const [price, setPrice] = useState(null); useEffect(() => { let active = true; // Guards against stale updates async function fetchPrice() { const data = await fetch(`/api/prices/${productId}`).then(r => r.json()); if (active) setPrice(data.price); } fetchPrice(); // Cleanup: unsubscribe when productId changes or component unmounts return () => { active = false; }; }, [productId]); // productId is the subject being observed return <div>Price: {price ?? 'Loading...'}</div>; }

When productId changes, React runs the cleanup (unsubscribe from the old value) then re-runs the effect (subscribe to the new value). Without the cleanup, a stale response for a previous product can update state after the component has already moved on.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?