Loading learning content...
In the previous page, we diagnosed the one-to-many dependency problem: a subject with multiple observers, naive direct coupling that creates maintenance nightmares, polling that wastes resources, and consistency challenges that lurk in the shadows. Now we turn to the solution.
The Observer Pattern solves these problems through a deceptively simple idea: publish-subscribe. Instead of the subject knowing about and calling each observer directly, observers subscribe to the subject, and the subject publishes notifications to all subscribers without knowing who they are.
This inversion—transforming 'subject calls observers' into 'observers register with subject'—is the conceptual core of the pattern. Everything else flows from this single insight.
By the end of this page, you will understand the publish-subscribe mechanism in depth: its conceptual model, the registration/notification flow, the formal interface contracts, and how it elegantly resolves the coupling problems we identified. You'll see complete implementations and understand the design decisions behind the pattern.
The publish-subscribe mechanism is built on a simple analogy: newspaper subscriptions.
This model maps directly to software:
| Newspaper World | Software World | Technical Term |
|---|---|---|
| Newspaper company | Object whose state changes | Subject / Observable |
| Newspaper edition | State change event | Notification / Update |
| Reader | Object interested in changes | Observer / Subscriber |
| Subscribe to newspaper | Register interest in subject | attach() / subscribe() |
| Cancel subscription | Remove interest in subject | detach() / unsubscribe() |
| Deliver newspaper | Notify all observers of change | notify() / publish() |
The key insight: The publisher doesn't care if subscribers read the newspaper, ignore it, or use it for papier-mâché. The publisher's job is simply to deliver content to everyone who has subscribed. What subscribers do with that content is entirely their concern.
Similarly, the subject in the Observer Pattern doesn't know or care what observers do when notified. Its sole responsibility is maintaining a list of observers and notifying them when state changes. This separation of concerns is what enables the pattern's flexibility.
Different sources use different terms: Subject/Observer (GoF), Publisher/Subscriber (messaging systems), Observable/Listener (Java), Observable/Observer (RxJS). They all describe the same fundamental relationship. We'll primarily use Subject/Observer as it's the classic GoF terminology.
The first half of publish-subscribe is registration: observers express their interest in a subject by subscribing to it. This replaces the naive approach where the subject is hardcoded to know about each observer.
The subject maintains a dynamic collection of observers—typically a list, set, or map. Observers can be added or removed at any time, without the subject's core logic changing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
/** * Subject Interface * * Defines the contract for any object that can be observed. * The subject manages a collection of observers and provides * methods for registration and notification. */interface Subject<T = void> { /** * Registers an observer to receive notifications. * The observer will be notified of all future state changes * until it is detached. * * @param observer The observer to add */ attach(observer: Observer<T>): void; /** * Removes a previously registered observer. * The observer will no longer receive notifications. * * @param observer The observer to remove */ detach(observer: Observer<T>): void; /** * Notifies all registered observers of a state change. * This triggers the update() method on each observer. */ notify(): void;} /** * Observer Interface * * Defines the contract for any object that wishes to be * notified of state changes in a subject. */interface Observer<T = void> { /** * Called by the subject when its state changes. * The observer should update its own state based on * the subject's new state. * * @param data Optional data passed from the subject */ update(data: T): void;}Why this interface works:
| Aspect | How the Interface Addresses It |
|---|---|
| Decoupling | Subject knows only Observer<T>, not concrete types |
| Dynamic membership | attach() and detach() enable runtime changes |
| Uniform interface | All observers implement the same update() method |
| Extensibility | New observer types just implement Observer<T> |
| Testability | Subject can be tested with mock observers |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
/** * Concrete Subject Implementation * * Demonstrates the standard implementation of subject * with observer management and notification. */class StockPrice implements Subject<StockUpdate> { private observers: Set<Observer<StockUpdate>> = new Set(); private symbol: string; private price: number = 0; private lastUpdated: Date = new Date(); constructor(symbol: string) { this.symbol = symbol; } // ========================================== // Observer Management (Subject interface) // ========================================== attach(observer: Observer<StockUpdate>): void { // Using Set prevents duplicate registrations if (this.observers.has(observer)) { console.warn('Observer already attached'); return; } this.observers.add(observer); console.log(`Observer attached. Total observers: ${this.observers.size}`); } detach(observer: Observer<StockUpdate>): void { const wasPresent = this.observers.delete(observer); if (!wasPresent) { console.warn('Observer was not attached'); return; } console.log(`Observer detached. Total observers: ${this.observers.size}`); } notify(): void { const update: StockUpdate = { symbol: this.symbol, price: this.price, timestamp: this.lastUpdated, }; // Iterate over all observers and call update() // Subject doesn't know or care what observers do with this for (const observer of this.observers) { observer.update(update); } } // ========================================== // Business Logic (StockPrice-specific) // ========================================== setPrice(newPrice: number): void { // Validate input if (newPrice < 0) { throw new Error('Price cannot be negative'); } // Skip notification if price hasn't changed if (newPrice === this.price) { return; } // Update state this.price = newPrice; this.lastUpdated = new Date(); // This is the key: after changing state, notify observers // The subject has NO idea who is listening or what they'll do this.notify(); } getPrice(): number { return this.price; }} interface StockUpdate { symbol: string; price: number; timestamp: Date;}We use a Set instead of an Array for observers. A Set automatically prevents duplicate registrations and provides O(1) lookup for detachment. With an Array, you'd need to check for duplicates manually and use indexOf() for removal, which is O(n).
The second half of publish-subscribe is notification: when the subject's state changes, it broadcasts that change to all registered observers. Let's trace through the complete flow:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
/** * Concrete Observer Implementations * * Each observer implements the same interface but reacts differently. * The subject knows nothing about these implementations. */ // Observer 1: Updates a visual displayclass TickerDisplay implements Observer<StockUpdate> { private displayElement: HTMLElement; constructor(elementId: string) { this.displayElement = document.getElementById(elementId)!; } update(data: StockUpdate): void { // This observer updates the DOM this.displayElement.textContent = `${data.symbol}: $${data.price.toFixed(2)}`; this.displayElement.classList.add('flash'); setTimeout(() => { this.displayElement.classList.remove('flash'); }, 300); }} // Observer 2: Recalculates portfolio valueclass PortfolioCalculator implements Observer<StockUpdate> { private holdings: Map<string, number> = new Map(); private prices: Map<string, number> = new Map(); constructor(holdings: Map<string, number>) { this.holdings = holdings; } update(data: StockUpdate): void { // This observer updates internal calculations this.prices.set(data.symbol, data.price); const totalValue = this.calculateTotalValue(); console.log(`Portfolio value: $${totalValue.toFixed(2)}`); } private calculateTotalValue(): number { let total = 0; for (const [symbol, quantity] of this.holdings) { const price = this.prices.get(symbol) || 0; total += price * quantity; } return total; }} // Observer 3: Triggers alerts when thresholds crossedclass AlertSystem implements Observer<StockUpdate> { private thresholds: Map<string, { low: number; high: number }> = new Map(); setThreshold(symbol: string, low: number, high: number): void { this.thresholds.set(symbol, { low, high }); } update(data: StockUpdate): void { // This observer checks conditions and sends alerts const threshold = this.thresholds.get(data.symbol); if (!threshold) return; if (data.price < threshold.low) { this.sendAlert(`⚠️ ${data.symbol} has fallen below $${threshold.low}`); } else if (data.price > threshold.high) { this.sendAlert(`🚀 ${data.symbol} has risen above $${threshold.high}`); } } private sendAlert(message: string): void { console.log(`ALERT: ${message}`); // Could also send email, push notification, etc. }} // Observer 4: Logs price history for analyticsclass PriceLogger implements Observer<StockUpdate> { private history: StockUpdate[] = []; update(data: StockUpdate): void { // This observer just logs data this.history.push({ ...data }); console.log( `[${data.timestamp.toISOString()}] ${data.symbol}: $${data.price}` ); } getHistory(): readonly StockUpdate[] { return this.history; }}Observe the elegance: Four completely different observers—a DOM updater, a calculator, an alert system, and a logger—all implement the same simple interface. The subject treats them uniformly, yet each responds in its own way.
123456789101112131415161718192021222324252627282930313233343536373839404142
// ==========================================// Usage: Wiring up the Observer Pattern// ========================================== // Create the subjectconst appleStock = new StockPrice('AAPL'); // Create observersconst tickerDisplay = new TickerDisplay('apple-ticker');const portfolio = new PortfolioCalculator( new Map([['AAPL', 100], ['GOOG', 50]]));const alertSystem = new AlertSystem();alertSystem.setThreshold('AAPL', 150, 200);const logger = new PriceLogger(); // Register observers with the subject// Note: Subject doesn't know what these observers are or doappleStock.attach(tickerDisplay);appleStock.attach(portfolio);appleStock.attach(alertSystem);appleStock.attach(logger); // Now, when the price changes, ALL observers are notified automaticallyappleStock.setPrice(175);// Output:// [2024-01-15T10:30:00.000Z] AAPL: $175// Portfolio value: $17500.00// (Ticker display updates in browser) appleStock.setPrice(205);// Output:// [2024-01-15T10:35:00.000Z] AAPL: $205// Portfolio value: $20500.00// ALERT: 🚀 AAPL has risen above $200// (Ticker display updates in browser) // Later, we can remove an observer without touching the subjectappleStock.detach(alertSystem); appleStock.setPrice(210);// Alert system no longer notified - no changes to StockPrice neededCompare this to the naive approach from the previous page. StockPrice no longer imports TickerDisplay, PortfolioCalculator, AlertSystem, or PriceLogger. It knows only about Observer<StockUpdate>. Adding a fifth observer requires zero changes to StockPrice.
Let's trace through exactly what happens when setPrice() is called. Understanding this sequence helps you debug issues and reason about timing:
123456789101112131415161718192021222324252627282930
┌─────────────┐ ┌────────────┐ ┌─────────────┐ ┌─────────────┐│ Client │ │ StockPrice │ │ Observer A │ │ Observer B ││ (Caller) │ │ (Subject) │ │ │ │ │└─────┬───────┘ └─────┬──────┘ └─────┬───────┘ └─────┬───────┘ │ │ │ │ │ setPrice(175) │ │ │ │─────────────────>│ │ │ │ │ │ │ │ [1. Validate input] │ │ │ [2. Update state] │ │ │ [3. Call notify()] │ │ │ │ │ │ │ │ update(data) │ │ │ │────────────────>│ │ │ │ │ │ │ │ [Observer A processes] │ │ │ │ │ │ │<────────────────│ │ │ │ │ │ │ │ update(data) │ │ │───────────────────────────────────>│ │ │ │ │ │ [Observer B processes] │ │ │ │ │ │<───────────────────────────────────│ │ │ │ │ │<─────────────────│ │ │ │ (returns) │ │ │ │ │ │ │ ▼ ▼ ▼ ▼Key observations from the sequence:
setPrice() doesn't return until ALL observers have completed their update() calls. This is important for understanding timing.update() takes 500ms, Observer B won't be notified until those 500ms elapse. Slow observers block the entire notification chain.StockUpdate object. If one observer mutates it (which they shouldn't), others see the mutation.setPrice() again, a nested notification cycle begins. This can cause infinite loops or unexpected behavior.The synchronous model works well when observers are fast. But if you have slow observers (file I/O, network calls), or observers that might fail, you may need asynchronous notification, error handling, or timeout mechanisms. We'll explore these variations in advanced implementations.
Let's systematically verify that the publish-subscribe mechanism addresses every problem we identified in the previous page:
| Problem | Naive Approach | Observer Pattern Solution |
|---|---|---|
| Compile-time coupling | Subject imports all observer classes | Subject knows only Observer interface |
| Knowledge coupling | Subject knows what each observer needs | Subject passes same data to all; observers extract what they need |
| Lifecycle coupling | Must initialize observers in specific order | Observers attach when ready, detach when done |
| Count coupling | Code changes when observer count changes | Collection iteration handles any count |
| Polling waste | Resources consumed checking for changes | Notifications pushed only when changes occur |
| Polling latency | Up to one interval between change and detection | Observers notified immediately on change |
| Open/Closed violation | Adding observers requires modifying subject | New observers implement interface; subject unchanged |
| SRP violation | Subject manages data AND orchestrates notifications | Subject delegates reaction logic to observers |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// ❌ BEFORE: Adding a fifth observer type // 1. Create the new observer classclass RiskAnalyzer { /* ... */ } // 2. Modify StockPrice to know about itclass StockPrice { private riskAnalyzer: RiskAnalyzer; // New field constructor( ticker: TickerDisplay, portfolio: PortfolioCalculator, alerts: AlertSystem, logger: PriceLogger, risk: RiskAnalyzer // New parameter ) { // ... this.riskAnalyzer = risk; } setPrice(price: number) { // ... this.riskAnalyzer.update(/* ... */); // New call }} // 3. Update all code that instantiates StockPrice// 4. Update all tests for StockPrice // ========================================= // ✅ AFTER: Adding a fifth observer type // 1. Create the new observer classclass RiskAnalyzer implements Observer<StockUpdate> { update(data: StockUpdate): void { // Handle risk analysis }} // 2. Attach it at runtimeconst riskAnalyzer = new RiskAnalyzer();appleStock.attach(riskAnalyzer); // That's it. StockPrice is UNCHANGED.// No modifications to existing code.// No test changes for StockPrice.The Observer Pattern is a textbook example of the Open/Closed Principle: the subject is open for extension (new observers can be added) but closed for modification (the subject's code never changes). This is the gold standard for maintainable systems.
The basic publish-subscribe mechanism admits several design variations. Each choice has tradeoffs:
| Collection | Duplicate Prevention | Order Guaranteed | Detach Performance | Best For |
|---|---|---|---|---|
| Array / List | Manual check needed | Yes (insertion order) | O(n) | Small observer counts, ordered notification |
| Set | Automatic | Yes (ES6 insertion order) | O(1) | General purpose, most common |
| WeakSet | Automatic | No iteration possible | O(1) | NOT usable (can't enumerate) |
| Map (key → observer) | Manual key management | Yes | O(1) by key | When observers need identifiers |
123456789101112131415161718192021222324252627282930313233343536373839404142
// Approach 1: Pass the entire subject (observer pulls data)interface Observer { update(subject: Subject): void;} // Observer accesses subject's state directlyclass TickerDisplay implements Observer { update(subject: StockPrice): void { const price = subject.getPrice(); // Pull data from subject const symbol = subject.getSymbol(); this.render(symbol, price); }} // ========================================= // Approach 2: Pass specific data (subject pushes data)interface Observer<T> { update(data: T): void;} // Observer receives exactly what it needsclass TickerDisplay implements Observer<StockUpdate> { update(data: StockUpdate): void { this.render(data.symbol, data.price); // Data already provided }} // ========================================= // Approach 3: Hybrid (pass subject AND specific data)interface Observer<T> { update(subject: Subject, data: T): void;} // Observer can use provided data or query for moreclass TickerDisplay implements Observer<StockUpdate> { update(subject: StockPrice, data: StockUpdate): void { this.render(data.symbol, data.price); // Can also access subject.getHistory() if needed }}Push vs Pull will be explored in depth in a later page. For now, recognize that both are valid; the choice depends on whether you want observers to be coupled to the subject's interface (pull) or want the subject to control exactly what data observers receive (push).
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Option 1: Return void (simple)attach(observer: Observer): void { this.observers.add(observer);}// Caller must keep reference to observer to detach later // ========================================= // Option 2: Return boolean (success/failure)attach(observer: Observer): boolean { if (this.observers.has(observer)) { return false; // Already attached } this.observers.add(observer); return true;} // ========================================= // Option 3: Return unsubscribe function (functional style)attach(observer: Observer): () => void { this.observers.add(observer); // Return a function that removes this specific observer return () => { this.observers.delete(observer); };} // Usage:const unsubscribe = stockPrice.attach(myObserver);// Later...unsubscribe(); // Clean detachment without keeping observer reference // ========================================= // Option 4: Return subscription object (RxJS style)attach(observer: Observer): Subscription { this.observers.add(observer); return { unsubscribe: () => this.observers.delete(observer), closed: false, };}Modern implementations increasingly favor returning an unsubscribe function. This pattern is popularized by RxJS and React's useEffect cleanup. It simplifies lifecycle management because the caller doesn't need to keep a reference to the observer—just the cleanup function.
What happens if an observer's update() method throws an error? The naive implementation propagates the error, stopping notification of remaining observers. This is usually undesirable:
123456789101112131415161718
// ❌ PROBLEMATIC: One bad observer breaks everythingclass StockPrice implements Subject<StockUpdate> { notify(): void { const data = this.createUpdateData(); for (const observer of this.observers) { observer.update(data); // If this throws, loop stops! } }} // Scenario:// - Observer A: Ticker Display (works fine)// - Observer B: Alert System (has a bug, throws error)// - Observer C: Portfolio Calculator (never gets notified!) // This violates observer independence:// One faulty observer affects all others12345678910111213141516171819202122232425262728293031323334353637383940
// ✅ ROBUST: Isolate observer failuresclass StockPrice implements Subject<StockUpdate> { private errorHandler: (error: Error, observer: Observer) => void; constructor(errorHandler?: (error: Error, observer: Observer) => void) { this.errorHandler = errorHandler || this.defaultErrorHandler; } notify(): void { const data = this.createUpdateData(); const errors: Array<{ observer: Observer; error: Error }> = []; for (const observer of this.observers) { try { observer.update(data); } catch (error) { // Log but continue to next observer errors.push({ observer, error: error as Error }); this.errorHandler(error as Error, observer); } } // Optionally: handle aggregated errors after all notifications if (errors.length > 0) { console.error(`${errors.length} observer(s) failed during notification`); } } private defaultErrorHandler(error: Error, observer: Observer): void { console.error('Observer update failed:', { observer: observer.constructor.name, error: error.message, stack: error.stack, }); }} // Now all observers receive notifications regardless of others' failures// Errors are logged for debugging// System remains stableThe subject should never let one observer's failure prevent others from being notified. This is a form of fault isolation—analogous to how a crashed browser tab shouldn't crash the entire browser. Always wrap observer calls in try-catch in production code.
We've now fully explored the core solution of the Observer Pattern. The publish-subscribe mechanism elegantly solves the one-to-many dependency problem by inverting the coupling direction:
update() method. The subject doesn't know concrete types.update(). No manual orchestration needed.What's next:
We've seen the interfaces and the flow. N the next page, we'll dive deeper into the Subject and Observer roles—their responsibilities, formal relationships, and how they participate in the pattern. We'll also introduce the complete UML structure and standard terminology used across the industry.
You now understand the publish-subscribe mechanism at the heart of the Observer Pattern. You can implement subjects and observers, understand the notification flow, and make informed design decisions about collection types, data passing, and error handling.