Observer Pattern: Event-Driven Communication
The Observer pattern defines a one-to-many dependency between objects. When one object (the subject) changes state, all its dependents (observers) are notified and updated automatically. This creates...
Key Insights
- The Observer pattern establishes a one-to-many dependency between objects, allowing subjects to notify multiple observers of state changes without tight coupling—forming the foundation of event-driven architectures.
- Choose between push (subject sends data) and pull (observers request data) models based on your use case: push for simple, uniform updates; pull when observers need different data subsets.
- Memory leaks from forgotten subscriptions are the most common Observer pattern bug—always implement and call unsubscribe mechanisms, especially in long-running applications.
What is the Observer Pattern?
The Observer pattern defines a one-to-many dependency between objects. When one object (the subject) changes state, all its dependents (observers) are notified and updated automatically. This creates a clean separation between the source of events and the consumers of those events.
Think of it like a newsletter subscription. When you subscribe to a newsletter, you become an observer. The newsletter publisher (subject) doesn’t need to know anything about you except that you want updates. When new content is published, all subscribers receive it. You can unsubscribe at any time without affecting other subscribers or the publisher.
Use the Observer pattern when you need to decouple components that must communicate, build event systems where multiple parts of your application react to changes, or create reactive UIs that update when underlying data changes.
Core Components and Structure
The pattern consists of two primary roles:
Subject (Observable): Maintains a list of observers, provides methods to add and remove observers, and notifies all observers when state changes occur.
Observer: Defines an update interface that subjects call when notifying of changes.
Here’s the foundational structure:
interface Observer<T> {
update(data: T): void;
}
interface Subject<T> {
subscribe(observer: Observer<T>): void;
unsubscribe(observer: Observer<T>): void;
notify(data: T): void;
}
abstract class BaseSubject<T> implements Subject<T> {
private observers: Set<Observer<T>> = new Set();
subscribe(observer: Observer<T>): void {
this.observers.add(observer);
}
unsubscribe(observer: Observer<T>): void {
this.observers.delete(observer);
}
notify(data: T): void {
this.observers.forEach(observer => observer.update(data));
}
}
Using a Set instead of an array prevents duplicate subscriptions and provides O(1) removal. This small detail matters when you have many observers or frequent subscription changes.
Basic Implementation
Let’s build a stock price tracker where multiple display widgets observe price changes:
interface StockPrice {
symbol: string;
price: number;
timestamp: Date;
}
class StockTicker extends BaseSubject<StockPrice> {
private prices: Map<string, number> = new Map();
updatePrice(symbol: string, price: number): void {
this.prices.set(symbol, price);
this.notify({
symbol,
price,
timestamp: new Date()
});
}
getPrice(symbol: string): number | undefined {
return this.prices.get(symbol);
}
}
class PriceDisplay implements Observer<StockPrice> {
constructor(private name: string) {}
update(data: StockPrice): void {
console.log(
`[${this.name}] ${data.symbol}: $${data.price.toFixed(2)}`
);
}
}
class PriceAlert implements Observer<StockPrice> {
constructor(
private symbol: string,
private threshold: number
) {}
update(data: StockPrice): void {
if (data.symbol === this.symbol && data.price > this.threshold) {
console.log(
`ALERT: ${this.symbol} exceeded $${this.threshold}!`
);
}
}
}
// Usage
const ticker = new StockTicker();
const mainDisplay = new PriceDisplay("Main Board");
const mobileDisplay = new PriceDisplay("Mobile App");
const appleAlert = new PriceAlert("AAPL", 150);
ticker.subscribe(mainDisplay);
ticker.subscribe(mobileDisplay);
ticker.subscribe(appleAlert);
ticker.updatePrice("AAPL", 148.50); // Both displays update
ticker.updatePrice("AAPL", 152.00); // Displays update + alert fires
ticker.updatePrice("GOOGL", 2800); // Only displays update
Each observer handles the same notification differently. The PriceDisplay shows all updates, while PriceAlert only reacts to specific conditions. The ticker doesn’t know or care what observers do with the data.
Push vs. Pull Models
The example above uses the push model—the subject sends all relevant data with each notification. This works well when observers need the same information.
The pull model sends minimal notifications, and observers query the subject for data they need:
// Pull model interfaces
interface PullObserver {
update(subject: WeatherStation): void;
}
class WeatherStation {
private observers: Set<PullObserver> = new Set();
private _temperature: number = 0;
private _humidity: number = 0;
private _pressure: number = 0;
get temperature(): number { return this._temperature; }
get humidity(): number { return this._humidity; }
get pressure(): number { return this._pressure; }
subscribe(observer: PullObserver): void {
this.observers.add(observer);
}
unsubscribe(observer: PullObserver): void {
this.observers.delete(observer);
}
setMeasurements(temp: number, humidity: number, pressure: number): void {
this._temperature = temp;
this._humidity = humidity;
this._pressure = pressure;
this.observers.forEach(obs => obs.update(this));
}
}
class TemperatureDisplay implements PullObserver {
update(station: WeatherStation): void {
// Only pulls temperature, ignores humidity and pressure
console.log(`Temperature: ${station.temperature}°C`);
}
}
class FullWeatherDisplay implements PullObserver {
update(station: WeatherStation): void {
// Pulls everything
console.log(
`Temp: ${station.temperature}°C, ` +
`Humidity: ${station.humidity}%, ` +
`Pressure: ${station.pressure} hPa`
);
}
}
Push advantages: Observers get everything they need immediately. No back-reference to subject required. Better for simple, uniform data needs.
Pull advantages: Observers only fetch what they need. Subject interface can evolve without changing notification signature. Better when observers have diverse data requirements.
In practice, I lean toward push for most cases. It’s simpler and avoids the coupling that pull creates between observers and subject internals.
Real-World Applications
The Observer pattern appears everywhere in modern development. Node.js EventEmitter is a classic example:
import { EventEmitter } from 'events';
class OrderService extends EventEmitter {
createOrder(order: Order): void {
// Save order logic...
this.emit('orderCreated', order);
}
cancelOrder(orderId: string): void {
// Cancel logic...
this.emit('orderCancelled', orderId);
}
}
const orderService = new OrderService();
// Multiple listeners (observers) for the same event
orderService.on('orderCreated', (order) => {
emailService.sendConfirmation(order);
});
orderService.on('orderCreated', (order) => {
inventoryService.reserveItems(order.items);
});
orderService.on('orderCreated', (order) => {
analyticsService.trackOrder(order);
});
Here’s a typed event emitter implementation that provides better safety:
type EventMap = Record<string, unknown>;
type EventCallback<T> = (data: T) => void;
class TypedEventEmitter<Events extends EventMap> {
private listeners: Map<keyof Events, Set<EventCallback<any>>> = new Map();
on<K extends keyof Events>(
event: K,
callback: EventCallback<Events[K]>
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
// Return unsubscribe function
return () => this.off(event, callback);
}
off<K extends keyof Events>(
event: K,
callback: EventCallback<Events[K]>
): void {
this.listeners.get(event)?.delete(callback);
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.listeners.get(event)?.forEach(callback => callback(data));
}
}
// Usage with type safety
interface AppEvents {
userLoggedIn: { userId: string; timestamp: Date };
userLoggedOut: { userId: string };
error: Error;
}
const events = new TypedEventEmitter<AppEvents>();
// TypeScript knows the callback parameter type
events.on('userLoggedIn', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
// This would be a type error:
// events.emit('userLoggedIn', { wrong: 'data' });
The return value from on() is an unsubscribe function—a pattern borrowed from reactive libraries that makes cleanup straightforward.
Pitfalls and Best Practices
Memory leaks are the biggest danger. Every subscription that isn’t cleaned up keeps both the observer and subject in memory:
class ComponentWithSubscription {
private unsubscribe: (() => void) | null = null;
mount(eventEmitter: TypedEventEmitter<AppEvents>): void {
this.unsubscribe = eventEmitter.on('userLoggedIn', (data) => {
this.handleLogin(data);
});
}
unmount(): void {
// Critical: clean up subscription
this.unsubscribe?.();
this.unsubscribe = null;
}
private handleLogin(data: { userId: string; timestamp: Date }): void {
// Handle the event
}
}
For managing multiple subscriptions, use a subscription manager:
class SubscriptionManager {
private subscriptions: (() => void)[] = [];
add(unsubscribe: () => void): void {
this.subscriptions.push(unsubscribe);
}
unsubscribeAll(): void {
this.subscriptions.forEach(unsub => unsub());
this.subscriptions = [];
}
}
// Usage
const subscriptions = new SubscriptionManager();
subscriptions.add(events.on('userLoggedIn', handleLogin));
subscriptions.add(events.on('userLoggedOut', handleLogout));
subscriptions.add(events.on('error', handleError));
// Later, clean up everything at once
subscriptions.unsubscribeAll();
Notification ordering can cause subtle bugs. Don’t rely on observers being notified in subscription order—if order matters, you need explicit priority handling or a different pattern.
Update cascades occur when an observer’s update triggers another notification, which triggers another, and so on. Guard against this with flags or by deferring updates:
class SafeSubject<T> extends BaseSubject<T> {
private isNotifying = false;
private pendingNotification: T | null = null;
notify(data: T): void {
if (this.isNotifying) {
this.pendingNotification = data;
return;
}
this.isNotifying = true;
super.notify(data);
this.isNotifying = false;
if (this.pendingNotification !== null) {
const pending = this.pendingNotification;
this.pendingNotification = null;
this.notify(pending);
}
}
}
Observer in Modern Architecture
The Observer pattern is the foundation for larger patterns. Publish-subscribe adds a message broker between publishers and subscribers, enabling cross-process communication. Message queues extend this with persistence and delivery guarantees. Reactive streams (RxJS, Reactor) add operators for transforming, filtering, and combining event streams.
Use native language features (DOM events, EventEmitter) when they fit. Build custom implementations when you need type safety, specific semantics, or integration with your domain model. Don’t reach for RxJS until you need its operators—simple observers are easier to debug.
The Observer pattern solves a fundamental problem: letting objects communicate without knowing about each other. Master it, and you’ll recognize it everywhere—in frameworks, libraries, and well-designed systems. Respect its simplicity, handle cleanup religiously, and it will serve you well.