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
useEffectare both Observer implementations
Quick example
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 inSubject 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.
// 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 useEffectSynchronous 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.
// 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.
// 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.
notify(data) {
this.observers.forEach(obs => {
try {
obs.update(data);
} catch (err) {
console.error('Observer error:', err);
}
});
}Real-world usage
- React:
useEffectdependency 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 viaSubscription - 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
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 eventUserStore 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.
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 printedWithout 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.
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 readyA concise answer to help you respond confidently on this topic during an interview.