Loading learning content...
In the Observer Pattern, when the subject's state changes, observers need to know about it. But how does the relevant data reach the observers? There are two fundamental approaches:
This choice has profound implications for coupling, performance, flexibility, and system design. Understanding both models—and when to use each—is essential for implementing the Observer Pattern effectively.
By the end of this page, you will deeply understand both push and pull models: how they work, their tradeoffs, implementation patterns, and when to choose each. You'll also learn about hybrid approaches that combine the best of both worlds.
In the push model, the subject proactively sends data to observers when state changes. The subject decides what information observers need and packages it into the notification. Observers receive a complete update payload without needing to query the subject.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
/** * PUSH MODEL * Subject sends data to observers; observers don't query subject. */ // Observer interface expects data payloadinterface Observer<T> { update(data: T): void; // Receives complete data package} // Update payload contains everything observers might needinterface StockUpdate { symbol: string; currentPrice: number; previousPrice: number; priceChange: number; percentChange: number; timestamp: Date; volume: number; high: number; low: number; openPrice: number;} class StockPricePush implements Subject<StockUpdate> { private observers: Set<Observer<StockUpdate>> = new Set(); // Internal state private symbol: string; private currentPrice: number = 0; private previousPrice: number = 0; private volume: number = 0; private high: number = 0; private low: number = Infinity; private openPrice: number = 0; private timestamp: Date = new Date(); constructor(symbol: string) { this.symbol = symbol; } attach(observer: Observer<StockUpdate>): void { this.observers.add(observer); } detach(observer: Observer<StockUpdate>): void { this.observers.delete(observer); } notify(): void { // PUSH: Subject constructs the complete update data const updateData: StockUpdate = { symbol: this.symbol, currentPrice: this.currentPrice, previousPrice: this.previousPrice, priceChange: this.currentPrice - this.previousPrice, percentChange: this.previousPrice > 0 ? ((this.currentPrice - this.previousPrice) / this.previousPrice) * 100 : 0, timestamp: this.timestamp, volume: this.volume, high: this.high, low: this.low, openPrice: this.openPrice, }; // PUSH: Subject sends data to each observer for (const observer of this.observers) { observer.update(updateData); // Data delivered directly } } setPrice(price: number, volume: number): void { this.previousPrice = this.currentPrice; this.currentPrice = price; this.volume = volume; this.timestamp = new Date(); // Update high/low if (price > this.high) this.high = price; if (price < this.low) this.low = price; this.notify(); }} // Observer implementation - purely reactiveclass TickerDisplayPush implements Observer<StockUpdate> { update(data: StockUpdate): void { // Observer uses data directly - no need to query subject console.log(`${data.symbol}: $${data.currentPrice.toFixed(2)}`); console.log(` Change: ${data.percentChange >= 0 ? '+' : ''}${data.percentChange.toFixed(2)}%`); console.log(` High: $${data.high.toFixed(2)}, Low: $${data.low.toFixed(2)}`); }}Characteristics of Push Model:
In the pull model, the subject merely notifies observers that something changed—often without any data. Observers then query the subject to get the specific information they need. Each observer decides what data to fetch.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
/** * PULL MODEL * Subject signals change; observers query for data they need. */ // Observer interface receives the subject referenceinterface ObserverPull<T extends SubjectPull<any>> { update(subject: T): void; // Receives subject, not data} interface SubjectPull<T extends SubjectPull<T>> { attach(observer: ObserverPull<T>): void; detach(observer: ObserverPull<T>): void; notify(): void;} class StockPricePull implements SubjectPull<StockPricePull> { private observers: Set<ObserverPull<StockPricePull>> = new Set(); // Same internal state private symbol: string; private currentPrice: number = 0; private previousPrice: number = 0; private volume: number = 0; private high: number = 0; private low: number = Infinity; private openPrice: number = 0; private timestamp: Date = new Date(); constructor(symbol: string) { this.symbol = symbol; } attach(observer: ObserverPull<StockPricePull>): void { this.observers.add(observer); } detach(observer: ObserverPull<StockPricePull>): void { this.observers.delete(observer); } notify(): void { // PULL: Subject sends itself, not data for (const observer of this.observers) { observer.update(this); // Pass subject reference } } // PULL: Subject exposes getters for observers to query getSymbol(): string { return this.symbol; } getCurrentPrice(): number { return this.currentPrice; } getPreviousPrice(): number { return this.previousPrice; } getVolume(): number { return this.volume; } getHigh(): number { return this.high; } getLow(): number { return this.low; } getOpenPrice(): number { return this.openPrice; } getTimestamp(): Date { return this.timestamp; } // Computed values getPriceChange(): number { return this.currentPrice - this.previousPrice; } getPercentChange(): number { return this.previousPrice > 0 ? ((this.currentPrice - this.previousPrice) / this.previousPrice) * 100 : 0; } setPrice(price: number, volume: number): void { this.previousPrice = this.currentPrice; this.currentPrice = price; this.volume = volume; this.timestamp = new Date(); if (price > this.high) this.high = price; if (price < this.low) this.low = price; this.notify(); }} // Observer implementation - actively queries subjectclass TickerDisplayPull implements ObserverPull<StockPricePull> { update(subject: StockPricePull): void { // PULL: Observer decides what data to fetch // Only get what's needed for this specific use case console.log(`${subject.getSymbol()}: $${subject.getCurrentPrice().toFixed(2)}`); console.log(` Change: ${subject.getPercentChange() >= 0 ? '+' : ''}${subject.getPercentChange().toFixed(2)}%`); }} class DetailedAnalyticsPull implements ObserverPull<StockPricePull> { update(subject: StockPricePull): void { // This observer needs more data - it pulls more console.log('=== Detailed Analytics ==='); console.log(`Symbol: ${subject.getSymbol()}`); console.log(`Price: $${subject.getCurrentPrice().toFixed(2)}`); console.log(`Previous: $${subject.getPreviousPrice().toFixed(2)}`); console.log(`Change: ${subject.getPriceChange().toFixed(2)} (${subject.getPercentChange().toFixed(2)}%)`); console.log(`Volume: ${subject.getVolume().toLocaleString()}`); console.log(`Day Range: $${subject.getLow().toFixed(2)} - $${subject.getHigh().toFixed(2)}`); console.log(`Timestamp: ${subject.getTimestamp().toISOString()}`); }}Characteristics of Pull Model:
Let's systematically compare the two models across multiple dimensions:
| Dimension | Push Model | Pull Model |
|---|---|---|
| Data flow direction | Subject → Observer (unidirectional) | Subject ← Observer (bidirectional) |
| Coupling direction | Observer depends on data type only | Observer depends on Subject interface |
| Communication count | 1 call per observer | 1 + N calls per observer (N = queries) |
| Data efficiency | May send unused data | Observers fetch only what they need |
| Snapshot consistency | Guaranteed (same object to all) | Not guaranteed (state may change) |
| Observer simplicity | Simple (just process data) | More complex (must query subject) |
| Subject API simplicity | Complex (must build update object) | Simple (just expose getters) |
| Adding new data fields | Change update type + subject | Add getter to subject; observers opt-in |
| Observer reusability | High (works with any data source) | Low (tied to specific Subject type) |
| Network/RPC suitability | Excellent (single message) | Poor (multiple round trips) |
In the pull model, if the subject's state changes while an observer is in the middle of multiple getter calls, the observer may see an inconsistent snapshot (e.g., old price but new volume). Mitigation: use immutable state snapshots or synchronization.
The pull model has a subtle but important consistency problem: state can change between queries. Let's examine this in detail:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
/** * CONSISTENCY PROBLEM IN PULL MODEL * * When an observer makes multiple getter calls, the subject's * state may change between calls, leading to inconsistent data. */ class ProblematicObserver implements ObserverPull<StockPricePull> { update(subject: StockPricePull): void { // First query const price = subject.getCurrentPrice(); // ⚠️ DANGER ZONE: If another thread updates the subject here, // subsequent queries will see different state! // In single-threaded JS, this can happen if subject.setPrice() // is called from within an async observer's callback // Second query const change = subject.getPriceChange(); // Third query const percent = subject.getPercentChange(); // These values may not be consistent with each other! // price might be 100, but change/-percent calculated from 105 console.log(`Price: ${price}, Change: ${change}, Percent: ${percent}`); // Could log: "Price: 100, Change: 5, Percent: 5%" // But change of 5 doesn't match price of 100! }} // Scenario that causes inconsistency:const stock = new StockPricePull('AAPL');stock.setPrice(100, 1000); // Set initial price // Observer 1 is slow, makes async callsconst slowObserver: ObserverPull<StockPricePull> = { async update(subject: StockPricePull) { const price = subject.getCurrentPrice(); // Gets 100 // Simulate async work (in real code: database call, etc.) await new Promise(resolve => setTimeout(resolve, 100)); // By now, price may have changed! const change = subject.getPriceChange(); // Calculated from 105! console.log(`Inconsistent view: price=${price}, change=${change}`); }}; // This update happens during slowObserver's async gapsetTimeout(() => stock.setPrice(105, 2000), 50);123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
/** * SOLUTION: Provide a snapshot mechanism in pull model */ interface StockSnapshot { readonly symbol: string; readonly currentPrice: number; readonly previousPrice: number; readonly priceChange: number; readonly percentChange: number; readonly volume: number; readonly high: number; readonly low: number; readonly timestamp: Date;} class StockPriceWithSnapshot implements SubjectPull<StockPriceWithSnapshot> { private observers: Set<ObserverPull<StockPriceWithSnapshot>> = new Set(); private symbol: string; private currentPrice: number = 0; private previousPrice: number = 0; private volume: number = 0; private high: number = 0; private low: number = Infinity; private timestamp: Date = new Date(); constructor(symbol: string) { this.symbol = symbol; } // ... attach, detach, notify as before ... /** * Returns an immutable snapshot of current state. * Observer can query this snapshot multiple times safely. */ getSnapshot(): StockSnapshot { const priceChange = this.currentPrice - this.previousPrice; const percentChange = this.previousPrice > 0 ? (priceChange / this.previousPrice) * 100 : 0; // Return immutable object capturing current state return Object.freeze({ symbol: this.symbol, currentPrice: this.currentPrice, previousPrice: this.previousPrice, priceChange, percentChange, volume: this.volume, high: this.high, low: this.low, timestamp: new Date(this.timestamp), // Defensive copy of Date }); } // Other methods...} // Observer uses snapshot for consistent viewclass ConsistentObserver implements ObserverPull<StockPriceWithSnapshot> { update(subject: StockPriceWithSnapshot): void { // ✅ CORRECT: Get snapshot once, use it for all operations const snapshot = subject.getSnapshot(); // All data from same snapshot - guaranteed consistent console.log(`${snapshot.symbol}: $${snapshot.currentPrice.toFixed(2)}`); console.log(`Change: $${snapshot.priceChange.toFixed(2)}`); console.log(`Percent: ${snapshot.percentChange.toFixed(2)}%`); // Even after async work, snapshot remains consistent this.asyncWork(snapshot); } private async asyncWork(snapshot: StockSnapshot): void { await someAsyncOperation(); // snapshot values are still consistent, even if subject changed console.log(`Processing snapshot from ${snapshot.timestamp}`); }}When using the pull model, provide a getSnapshot() method that returns an immutable object capturing the complete state at a point in time. This gives observers a consistent view they can work with safely.
In practice, pure push or pure pull is often too restrictive. Hybrid approaches combine the benefits of both models. Let's examine several hybrid patterns:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
/** * HYBRID: Subject pushes a "hint" about what changed; * observers pull full data only if interested. */ interface ChangeHint { changeType: 'price' | 'volume' | 'both'; symbol: string; timestamp: Date;} interface HybridObserver { update(hint: ChangeHint, subject: StockPriceHybrid): void;} class StockPriceHybrid { private observers: Set<HybridObserver> = new Set(); private symbol: string; private price: number = 0; private volume: number = 0; // ... attach, detach ... notify(changeType: ChangeHint['changeType']): void { const hint: ChangeHint = { changeType, symbol: this.symbol, timestamp: new Date(), }; for (const observer of this.observers) { observer.update(hint, this); // Push hint + provide subject } } setPrice(price: number): void { this.price = price; this.notify('price'); } setVolume(volume: number): void { this.volume = volume; this.notify('volume'); } // Getters for pull getPrice(): number { return this.price; } getVolume(): number { return this.volume; } getFullData(): { price: number; volume: number } { return { price: this.price, volume: this.volume }; }} // Observer uses hint to decide whether to pullclass SelectiveObserver implements HybridObserver { constructor(private careAboutVolume: boolean) {} update(hint: ChangeHint, subject: StockPriceHybrid): void { // Check hint to avoid unnecessary work if (hint.changeType === 'volume' && !this.careAboutVolume) { return; // Skip volume-only changes if we don't care } // Pull only when interested if (hint.changeType === 'price' || hint.changeType === 'both') { const price = subject.getPrice(); console.log(`Price update: $${price}`); } }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
/** * HYBRID: Subject pushes essential data (what most observers need); * observers can pull additional data if needed. */ interface EssentialUpdate { symbol: string; price: number; timestamp: Date;} interface ExtendedPullObserver { update(data: EssentialUpdate, subject: StockPriceExtended): void;} class StockPriceExtended { private observers: Set<ExtendedPullObserver> = new Set(); private symbol: string; private price: number = 0; private previousPrice: number = 0; private volume: number = 0; private high: number = 0; private low: number = Infinity; private history: Array<{ price: number; time: Date }> = []; // ... constructor, attach, detach ... notify(): void { // PUSH essential data const essential: EssentialUpdate = { symbol: this.symbol, price: this.price, timestamp: new Date(), }; for (const observer of this.observers) { observer.update(essential, this); // Push + pull capability } } // Extended data available via pull getPreviousPrice(): number { return this.previousPrice; } getVolume(): number { return this.volume; } getHigh(): number { return this.high; } getLow(): number { return this.low; } getHistory(): ReadonlyArray<{ price: number; time: Date }> { return this.history; }} // Simple observer uses only pushed dataclass SimpleDisplay implements ExtendedPullObserver { update(data: EssentialUpdate, subject: StockPriceExtended): void { // Uses only pushed data - simple and efficient console.log(`${data.symbol}: $${data.price.toFixed(2)}`); }} // Complex observer pulls additional dataclass AnalyticsEngine implements ExtendedPullObserver { update(data: EssentialUpdate, subject: StockPriceExtended): void { // Uses pushed essential data console.log(`=== ${data.symbol} Analysis ===`); console.log(`Current: $${data.price.toFixed(2)}`); // Pulls extended data only when needed const history = subject.getHistory(); if (history.length >= 5) { const recentPrices = history.slice(-5).map(h => h.price); const avg = recentPrices.reduce((a, b) => a + b, 0) / 5; console.log(`5-period average: $${avg.toFixed(2)}`); } const dayRange = subject.getHigh() - subject.getLow(); console.log(`Day range: $${dayRange.toFixed(2)}`); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
/** * HYBRID: Subject pushes an immutable snapshot containing * all data, giving observers the consistency of push with * the flexibility to use only what they need. */ interface CompleteSnapshot { readonly symbol: string; readonly currentPrice: number; readonly previousPrice: number; readonly priceChange: number; readonly percentChange: number; readonly volume: number; readonly high: number; readonly low: number; readonly timestamp: Date; readonly bidPrice: number; readonly askPrice: number; readonly spread: number;} interface SnapshotObserver { update(snapshot: CompleteSnapshot): void;} class StockPriceSnapshot { private observers: Set<SnapshotObserver> = new Set(); // All state fields... private symbol: string; private currentPrice: number = 0; private previousPrice: number = 0; private volume: number = 0; private high: number = 0; private low: number = Infinity; private bidPrice: number = 0; private askPrice: number = 0; // ... attach, detach ... private createSnapshot(): CompleteSnapshot { const priceChange = this.currentPrice - this.previousPrice; return Object.freeze({ symbol: this.symbol, currentPrice: this.currentPrice, previousPrice: this.previousPrice, priceChange, percentChange: this.previousPrice > 0 ? (priceChange / this.previousPrice) * 100 : 0, volume: this.volume, high: this.high, low: this.low, timestamp: new Date(), bidPrice: this.bidPrice, askPrice: this.askPrice, spread: this.askPrice - this.bidPrice, }); } notify(): void { // PUSH: Create snapshot once, share with all observers const snapshot = this.createSnapshot(); for (const observer of this.observers) { observer.update(snapshot); // All see same consistent data } }} // Different observers use different parts of the snapshotclass TickerObserver implements SnapshotObserver { update(snapshot: CompleteSnapshot): void { // Uses only what it needs from the pushed snapshot console.log(`${snapshot.symbol}: $${snapshot.currentPrice.toFixed(2)}`); }} class SpreadMonitor implements SnapshotObserver { update(snapshot: CompleteSnapshot): void { // Uses different fields from the same snapshot if (snapshot.spread > 0.10) { console.log(`Wide spread alert: ${snapshot.symbol} spread $${snapshot.spread.toFixed(2)}`); } }}For most applications, pushing an immutable snapshot is the best hybrid approach. It provides: consistency (all observers see the same data), efficiency (one snapshot creation, reused by all), and flexibility (observers use only the fields they need).
Let's examine how major frameworks and systems choose between push and pull:
| System/Framework | Model Used | Rationale |
|---|---|---|
| React useState/useReducer | Push (state passed to render) | Simple, consistent, enables efficient reconciliation |
| Redux | Hybrid (dispatch/select) | Actions push changes; selectors pull specific data |
| RxJS Observables | Push (Observer.next(value)) | Stream semantics require pushed values |
| Java Observable (deprecated) | Pull (Observer gets Observable) | Original GoF design; deprecated due to inflexibility |
| DOM Events | Push (event object) | Event contains all relevant data |
| Kafka/Message Queues | Push (message to consumer) | Distributed systems need single-message delivery |
| Database Triggers | Pull (trigger queries DB) | Trigger fires signal; business logic queries data |
| GraphQL Subscriptions | Push (typed response) | Clients specify needed fields; server pushes matching data |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
/** * REDUX: Hybrid Push/Pull Design * * Redux demonstrates a sophisticated hybrid approach: * - Actions PUSH intent (what happened) * - Selectors PULL derived state (what component needs) */ // Store manages stateinterface ReduxStore<State> { getState(): State; dispatch(action: Action): void; subscribe(listener: () => void): () => void;} interface Action { type: string; payload?: any;} // Actions PUSH information about what happenedconst priceUpdated = (symbol: string, price: number): Action => ({ type: 'PRICE_UPDATED', payload: { symbol, price },}); // Reducers are notified (pushed) to produce new statefunction priceReducer(state: PriceState, action: Action): PriceState { switch (action.type) { case 'PRICE_UPDATED': return { ...state, [action.payload.symbol]: action.payload.price, }; default: return state; }} // Subscribers receive minimal push (just "something changed")store.subscribe(() => { // This is the PULL part: subscriber queries for specific data const applePrice = selectPrice(store.getState(), 'AAPL'); updateDisplay(applePrice);}); // Selectors PULL specific data from stateconst selectPrice = (state: RootState, symbol: string): number | undefined => { return state.prices[symbol];}; // Why this hybrid works well:// - Actions are self-contained (push complete intent)// - Subscribers aren't overwhelmed with data (minimal signal)// - Components pull exactly what they need (efficient re-renders)// - Selectors can compute derived data (lazy evaluation)Modern reactive systems increasingly favor push-based models. RxJS, React, Apollo GraphQL, and most event streaming platforms push data. The shift toward immutable data and functional programming makes push's consistency guarantees more valuable.
Based on our analysis, here are practical guidelines for choosing and implementing push vs pull:
getSnapshot() method for consistent reads.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
/** * PRODUCTION TEMPLATE: Push Model with Snapshot * * This is a recommended general-purpose implementation. */ // Immutable update typeinterface UpdateData<T> { readonly snapshot: T; readonly timestamp: Date; readonly changeType: string;} // Simple observer interfaceinterface Observer<T> { update(data: UpdateData<T>): void;} // Abstract subject with all the boilerplateabstract class AbstractSubject<State> { private observers: Set<Observer<State>> = new Set(); attach(observer: Observer<State>): () => void { this.observers.add(observer); return () => this.detach(observer); } detach(observer: Observer<State>): void { this.observers.delete(observer); } protected notify(changeType: string): void { const snapshot = this.getSnapshot(); const data: UpdateData<State> = Object.freeze({ snapshot, timestamp: new Date(), changeType, }); for (const observer of this.observers) { try { observer.update(data); } catch (error) { console.error('Observer error:', error); } } } // Subclasses implement these abstract getSnapshot(): State;} // Concrete implementationinterface StockState { readonly symbol: string; readonly price: number; readonly volume: number;} class Stock extends AbstractSubject<StockState> { private _symbol: string; private _price: number = 0; private _volume: number = 0; constructor(symbol: string) { super(); this._symbol = symbol; } getSnapshot(): StockState { return Object.freeze({ symbol: this._symbol, price: this._price, volume: this._volume, }); } setPrice(price: number): void { this._price = price; this.notify('price'); } setVolume(volume: number): void { this._volume = volume; this.notify('volume'); }}We've thoroughly explored the two fundamental approaches to data flow in the Observer Pattern. Let's consolidate our understanding:
Module Complete:
You've now completed the Observer Pattern module. You understand the problem (one-to-many dependencies), the solution (publish-subscribe), the participants (Subject and Observer), and the data flow models (push vs pull). You're equipped to implement the Observer Pattern in production systems, choose appropriate variations for your use case, and recognize the pattern in existing frameworks.
Congratulations! You've mastered the Observer Pattern—one of the most widely-used behavioral patterns in software engineering. From UI frameworks to distributed systems, from real-time data feeds to configuration management, the Observer Pattern enables decoupled, maintainable, and scalable event-driven architectures.