Episode 9 — System Design / 9.5 — Behavioral Design Patterns
9.5.a Observer Pattern
Overview
The Observer Pattern defines a one-to-many dependency between objects so that when one object (the Subject/Publisher) changes state, all its dependents (Observers/Subscribers) are notified and updated automatically.
This is arguably the most important behavioral pattern in JavaScript -- it's the foundation of event-driven programming, reactive frameworks, and real-time systems.
+-----------------------------------------------------------+
| OBSERVER PATTERN |
| |
| Subject (Publisher) Observers (Subscribers) |
| +------------------+ +-------------------+ |
| | |----->| Observer A | |
| | - observers[] | +-------------------+ |
| | - state | |
| | |----->+-------------------+ |
| | + subscribe() | | Observer B | |
| | + unsubscribe() | +-------------------+ |
| | + notify() | |
| | |----->+-------------------+ |
| +------------------+ | Observer C | |
| +-------------------+ |
| |
| When state changes --> ALL observers get notified |
+-----------------------------------------------------------+
The Problem: Tight Coupling
Imagine a stock price system where multiple displays need updates:
// BAD: Tight coupling -- the StockTicker knows about every display
class StockTicker {
constructor() {
this.priceDisplay = new PriceDisplay();
this.chartDisplay = new ChartDisplay();
this.alertSystem = new AlertSystem();
this.logger = new Logger();
}
updatePrice(symbol, price) {
this.price = price;
// Must manually update EVERY dependent
this.priceDisplay.update(symbol, price);
this.chartDisplay.update(symbol, price);
this.alertSystem.check(symbol, price);
this.logger.log(symbol, price);
// Adding a new display means modifying THIS class!
}
}
Problems:
- Adding a new observer requires modifying the Subject class (violates Open/Closed Principle)
- Subject is tightly coupled to all concrete observer types
- Cannot dynamically add/remove observers at runtime
- Hard to test in isolation
The Solution: Observer Pattern
+-----------+ subscribe(observer) +------------+
| |<--------------------------------------+ |
| Subject | unsubscribe(observer) | Observer |
| (Publisher)|<--------------------------------------+ (Subscriber)|
| | | |
| |------------ notify() ----------------->| |
+-----------+ (calls update()) +------------+
| ^
| 1. State changes |
| 2. Loops through observers[] |
| 3. Calls update() on each |
+---------------------------------------------------+
Implementation 1: Basic Observer Pattern
// ============================================
// OBSERVER INTERFACE
// ============================================
class Observer {
update(data) {
throw new Error('Observer.update() must be implemented');
}
}
// ============================================
// SUBJECT (PUBLISHER)
// ============================================
class Subject {
constructor() {
this.observers = new Set(); // Using Set to prevent duplicates
}
subscribe(observer) {
this.observers.add(observer);
console.log(`[Subject] Observer subscribed. Total: ${this.observers.size}`);
return this; // Enable chaining
}
unsubscribe(observer) {
this.observers.delete(observer);
console.log(`[Subject] Observer unsubscribed. Total: ${this.observers.size}`);
return this;
}
notify(data) {
console.log(`[Subject] Notifying ${this.observers.size} observers...`);
for (const observer of this.observers) {
try {
observer.update(data);
} catch (error) {
console.error(`[Subject] Observer error: ${error.message}`);
// Don't let one bad observer break the chain
}
}
}
}
// ============================================
// CONCRETE SUBJECT: Stock Ticker
// ============================================
class StockTicker extends Subject {
constructor() {
super();
this.prices = new Map();
}
updatePrice(symbol, price) {
const oldPrice = this.prices.get(symbol) || 0;
this.prices.set(symbol, price);
const change = price - oldPrice;
const changePercent = oldPrice ? ((change / oldPrice) * 100).toFixed(2) : 0;
// Notify all observers with rich data
this.notify({
symbol,
price,
oldPrice,
change,
changePercent,
timestamp: Date.now()
});
}
getPrice(symbol) {
return this.prices.get(symbol);
}
}
// ============================================
// CONCRETE OBSERVERS
// ============================================
class PriceDisplay extends Observer {
constructor(name) {
super();
this.name = name;
}
update(data) {
const arrow = data.change >= 0 ? '▲' : '▼';
console.log(
`[${this.name}] ${data.symbol}: $${data.price.toFixed(2)} ` +
`${arrow} ${data.changePercent}%`
);
}
}
class PriceAlert extends Observer {
constructor(symbol, threshold, direction) {
super();
this.symbol = symbol;
this.threshold = threshold;
this.direction = direction; // 'above' or 'below'
}
update(data) {
if (data.symbol !== this.symbol) return;
if (this.direction === 'above' && data.price > this.threshold) {
console.log(
`[ALERT] ${data.symbol} is ABOVE $${this.threshold}! ` +
`Current: $${data.price.toFixed(2)}`
);
} else if (this.direction === 'below' && data.price < this.threshold) {
console.log(
`[ALERT] ${data.symbol} is BELOW $${this.threshold}! ` +
`Current: $${data.price.toFixed(2)}`
);
}
}
}
class TradeLogger extends Observer {
constructor() {
super();
this.log = [];
}
update(data) {
this.log.push({
...data,
loggedAt: new Date().toISOString()
});
console.log(
`[Logger] Recorded: ${data.symbol} @ $${data.price.toFixed(2)} ` +
`(${this.log.length} entries total)`
);
}
getLog() {
return [...this.log];
}
}
// ============================================
// USAGE
// ============================================
const ticker = new StockTicker();
const mainDisplay = new PriceDisplay('Main Board');
const mobileDisplay = new PriceDisplay('Mobile App');
const appleAlert = new PriceAlert('AAPL', 200, 'above');
const logger = new TradeLogger();
// Subscribe observers
ticker.subscribe(mainDisplay);
ticker.subscribe(mobileDisplay);
ticker.subscribe(appleAlert);
ticker.subscribe(logger);
// Price updates automatically notify all observers
ticker.updatePrice('AAPL', 195.50);
ticker.updatePrice('GOOG', 2800.00);
ticker.updatePrice('AAPL', 205.75); // Triggers alert!
// Dynamically unsubscribe
ticker.unsubscribe(mobileDisplay);
ticker.updatePrice('AAPL', 210.00); // Mobile won't get this
The DOM Events Analogy
The Observer pattern is everywhere in the browser:
// DOM Events ARE the Observer pattern!
//
// Subject (Publisher) = button element
// subscribe() = addEventListener()
// unsubscribe() = removeEventListener()
// notify() = dispatchEvent() / browser triggers event
// Observer (Subscriber) = event handler function
const button = document.getElementById('myButton');
// "Subscribing" observers to the "click" subject
function handleClick(event) {
console.log('Button was clicked!', event);
}
function logClick(event) {
analytics.track('button_click', { id: event.target.id });
}
function animateClick(event) {
event.target.classList.add('pulse');
}
// Subscribe multiple observers to the same event
button.addEventListener('click', handleClick);
button.addEventListener('click', logClick);
button.addEventListener('click', animateClick);
// Unsubscribe one observer
button.removeEventListener('click', logClick);
// Under the hood, when you click the button:
// 1. Browser creates the Event object
// 2. Browser loops through all registered listeners
// 3. Each listener's callback is invoked with the Event
// This IS the Observer pattern!
+------------------------------------------------------------------+
| DOM Events = Observer Pattern |
| |
| addEventListener('click', fn) = subject.subscribe(observer) |
| removeEventListener('click', fn) = subject.unsubscribe(observer) |
| dispatchEvent(new Event('click'))= subject.notify(data) |
| event handler function = observer.update(data) |
+------------------------------------------------------------------+
Implementation 2: EventEmitter (Node.js Style)
// ============================================
// CUSTOM EVENT EMITTER (Node.js EventEmitter pattern)
// ============================================
class EventEmitter {
constructor() {
this.events = new Map(); // eventName -> Set of listeners
}
// Subscribe to a named event
on(eventName, listener) {
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName).add(listener);
return this; // Chainable
}
// Subscribe for ONE emission only
once(eventName, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(eventName, wrapper);
};
wrapper.originalListener = listener;
return this.on(eventName, wrapper);
}
// Unsubscribe
off(eventName, listener) {
const listeners = this.events.get(eventName);
if (!listeners) return this;
listeners.delete(listener);
// Also check for 'once' wrappers
for (const fn of listeners) {
if (fn.originalListener === listener) {
listeners.delete(fn);
}
}
if (listeners.size === 0) {
this.events.delete(eventName);
}
return this;
}
// Emit an event (notify)
emit(eventName, ...args) {
const listeners = this.events.get(eventName);
if (!listeners || listeners.size === 0) {
return false;
}
// Create a copy so removals during iteration don't cause issues
const listenersCopy = [...listeners];
for (const listener of listenersCopy) {
try {
listener(...args);
} catch (error) {
console.error(`Error in '${eventName}' listener:`, error);
}
}
return true;
}
// Remove all listeners for an event (or all events)
removeAllListeners(eventName) {
if (eventName) {
this.events.delete(eventName);
} else {
this.events.clear();
}
return this;
}
// Get listener count
listenerCount(eventName) {
const listeners = this.events.get(eventName);
return listeners ? listeners.size : 0;
}
// Get all event names
eventNames() {
return [...this.events.keys()];
}
}
// ============================================
// REAL-WORLD: User Authentication System
// ============================================
class AuthService extends EventEmitter {
constructor() {
super();
this.currentUser = null;
this.sessions = new Map();
}
async login(email, password) {
try {
this.emit('login:attempt', { email });
// Simulate authentication
const user = await this.authenticate(email, password);
this.currentUser = user;
const session = {
id: `sess_${Date.now()}`,
userId: user.id,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000)
};
this.sessions.set(session.id, session);
this.emit('login:success', { user, session });
return { user, session };
} catch (error) {
this.emit('login:failure', { email, error: error.message });
throw error;
}
}
async logout(sessionId) {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
this.currentUser = null;
this.emit('logout', { sessionId, userId: session.userId });
}
}
async authenticate(email, password) {
// Simulated async auth
return new Promise((resolve, reject) => {
setTimeout(() => {
if (password.length >= 8) {
resolve({ id: `user_${Date.now()}`, email, name: 'Test User' });
} else {
reject(new Error('Invalid credentials'));
}
}, 100);
});
}
}
// ============================================
// USAGE: Multiple systems react to auth events
// ============================================
const auth = new AuthService();
// Analytics observer
auth.on('login:success', ({ user }) => {
console.log(`[Analytics] User ${user.email} logged in`);
});
auth.on('login:failure', ({ email, error }) => {
console.log(`[Analytics] Failed login for ${email}: ${error}`);
});
// Security observer
let failedAttempts = {};
auth.on('login:failure', ({ email }) => {
failedAttempts[email] = (failedAttempts[email] || 0) + 1;
if (failedAttempts[email] >= 3) {
console.log(`[Security] Account ${email} locked after 3 failures`);
}
});
// Welcome email observer (one-time for first login)
auth.once('login:success', ({ user }) => {
console.log(`[Email] Sending welcome email to ${user.email}`);
});
// Session manager observer
auth.on('logout', ({ sessionId }) => {
console.log(`[SessionMgr] Cleaning up session ${sessionId}`);
});
// All these run independently when auth events fire!
// auth.login('user@example.com', 'password123');
Implementation 3: Reactive Data Binding
// ============================================
// REACTIVE STATE (Vue/MobX inspired)
// ============================================
class ReactiveState {
constructor(initialState = {}) {
this._state = { ...initialState };
this._watchers = new Map(); // property -> Set of callbacks
this._computed = new Map(); // name -> { deps, compute, cached }
this._globalWatchers = new Set();
// Return a Proxy that triggers notifications on set
return new Proxy(this, {
get(target, prop) {
// If it's a ReactiveState method, return it
if (prop in target && typeof target[prop] === 'function') {
return target[prop].bind(target);
}
// If it's a computed property
if (target._computed.has(prop)) {
return target._getComputed(prop);
}
return target._state[prop];
},
set(target, prop, value) {
if (prop.startsWith('_')) {
target[prop] = value;
return true;
}
const oldValue = target._state[prop];
if (oldValue === value) return true; // No change
target._state[prop] = value;
target._notifyWatchers(prop, value, oldValue);
return true;
}
});
}
watch(property, callback) {
if (!this._watchers.has(property)) {
this._watchers.set(property, new Set());
}
this._watchers.get(property).add(callback);
// Return unwatch function
return () => {
this._watchers.get(property)?.delete(callback);
};
}
watchAll(callback) {
this._globalWatchers.add(callback);
return () => this._globalWatchers.delete(callback);
}
computed(name, deps, computeFn) {
this._computed.set(name, {
deps,
compute: computeFn,
cached: null,
dirty: true
});
// Mark computed as dirty when any dependency changes
for (const dep of deps) {
this.watch(dep, () => {
this._computed.get(name).dirty = true;
});
}
}
_getComputed(name) {
const comp = this._computed.get(name);
if (comp.dirty) {
comp.cached = comp.compute(this._state);
comp.dirty = false;
}
return comp.cached;
}
_notifyWatchers(prop, newValue, oldValue) {
// Property-specific watchers
const watchers = this._watchers.get(prop);
if (watchers) {
for (const cb of watchers) {
cb(newValue, oldValue, prop);
}
}
// Global watchers
for (const cb of this._globalWatchers) {
cb(prop, newValue, oldValue);
}
}
}
// ============================================
// USAGE
// ============================================
const store = new ReactiveState({
firstName: 'John',
lastName: 'Doe',
items: [],
taxRate: 0.08
});
// Watch specific properties
const unwatchName = store.watch('firstName', (newVal, oldVal) => {
console.log(`Name changed: ${oldVal} -> ${newVal}`);
});
// Computed property
store.computed('fullName', ['firstName', 'lastName'], (state) => {
return `${state.firstName} ${state.lastName}`;
});
// Watch all changes (global observer)
store.watchAll((prop, newVal, oldVal) => {
console.log(`[Global] ${prop}: ${JSON.stringify(oldVal)} -> ${JSON.stringify(newVal)}`);
});
// Trigger changes
store.firstName = 'Jane'; // Notifies firstName watchers + global
store.lastName = 'Smith'; // Notifies lastName watchers + global
console.log(store.fullName); // "Jane Smith" (computed)
unwatchName(); // Stop watching firstName
store.firstName = 'Bob'; // Only global watcher fires
Implementation 4: Real-Time Notification System
// ============================================
// NOTIFICATION CENTER
// ============================================
class NotificationCenter {
constructor() {
this.channels = new Map(); // channel -> { subscribers, history }
this.userPreferences = new Map();
}
createChannel(channelName, options = {}) {
if (this.channels.has(channelName)) {
throw new Error(`Channel '${channelName}' already exists`);
}
this.channels.set(channelName, {
subscribers: new Map(), // userId -> { callback, filters }
history: [],
maxHistory: options.maxHistory || 100,
createdAt: Date.now()
});
return this;
}
subscribe(channelName, userId, callback, filters = {}) {
const channel = this.channels.get(channelName);
if (!channel) throw new Error(`Channel '${channelName}' not found`);
channel.subscribers.set(userId, { callback, filters });
console.log(
`[NotifCenter] ${userId} subscribed to #${channelName} ` +
`(${channel.subscribers.size} subscribers)`
);
// Return unsubscribe function
return () => this.unsubscribe(channelName, userId);
}
unsubscribe(channelName, userId) {
const channel = this.channels.get(channelName);
if (channel) {
channel.subscribers.delete(userId);
console.log(`[NotifCenter] ${userId} unsubscribed from #${channelName}`);
}
}
publish(channelName, notification) {
const channel = this.channels.get(channelName);
if (!channel) throw new Error(`Channel '${channelName}' not found`);
const enriched = {
id: `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
channel: channelName,
timestamp: Date.now(),
...notification
};
// Add to history
channel.history.push(enriched);
if (channel.history.length > channel.maxHistory) {
channel.history.shift();
}
// Notify matching subscribers
let delivered = 0;
for (const [userId, { callback, filters }] of channel.subscribers) {
if (this._matchesFilters(enriched, filters)) {
try {
callback(enriched, userId);
delivered++;
} catch (error) {
console.error(`[NotifCenter] Error delivering to ${userId}:`, error);
}
}
}
console.log(
`[NotifCenter] Published to #${channelName}: ` +
`${delivered}/${channel.subscribers.size} delivered`
);
return enriched;
}
_matchesFilters(notification, filters) {
for (const [key, value] of Object.entries(filters)) {
if (Array.isArray(value)) {
if (!value.includes(notification[key])) return false;
} else if (notification[key] !== value) {
return false;
}
}
return true;
}
getHistory(channelName, limit = 10) {
const channel = this.channels.get(channelName);
if (!channel) return [];
return channel.history.slice(-limit);
}
}
// ============================================
// USAGE
// ============================================
const notifCenter = new NotificationCenter();
notifCenter.createChannel('orders', { maxHistory: 50 });
notifCenter.createChannel('system-alerts');
// Different departments subscribe with filters
notifCenter.subscribe('orders', 'warehouse', (notif) => {
console.log(` [Warehouse] New order ${notif.orderId} needs packing`);
}, { type: 'new_order' });
notifCenter.subscribe('orders', 'billing', (notif) => {
console.log(` [Billing] Process payment for order ${notif.orderId}`);
}, { type: ['new_order', 'refund'] });
notifCenter.subscribe('orders', 'analytics', (notif) => {
console.log(` [Analytics] Tracking: ${notif.type} - $${notif.amount}`);
}); // No filters -- gets everything
// Publish events
notifCenter.publish('orders', {
type: 'new_order',
orderId: 'ORD-001',
amount: 99.99
});
notifCenter.publish('orders', {
type: 'refund',
orderId: 'ORD-001',
amount: 99.99
});
// Warehouse doesn't get refund (filtered out), but billing and analytics do
Observer vs Pub/Sub
+------------------------------------------------------------------+
| |
| OBSERVER (Direct) PUB/SUB (Via Broker) |
| |
| Subject -----> Observer Publisher ---> [Broker] ---> Sub |
| Subject -----> Observer Publisher ---> [Broker] ---> Sub |
| |
| - Subject KNOWS about - Publisher DOESN'T know |
| observers directly subscribers |
| - Synchronous usually - Can be async |
| - Same process - Can cross processes |
| - Tightly coupled (a bit) - Fully decoupled |
| |
| Examples: Examples: |
| - DOM events - Redis Pub/Sub |
| - EventEmitter - Kafka topics |
| - RxJS Observables - AWS SNS/SQS |
| - Vue reactivity - RabbitMQ exchanges |
| |
+------------------------------------------------------------------+
Common Pitfalls
1. Memory Leaks (Forgotten Subscriptions)
// BAD: Observer is never unsubscribed
class UserComponent {
constructor(store) {
// This creates a reference that prevents garbage collection!
store.subscribe('user', (user) => {
this.render(user);
});
}
// When UserComponent is "destroyed", the subscription lives on!
}
// GOOD: Always clean up subscriptions
class UserComponent {
constructor(store) {
this.unsubscribe = store.subscribe('user', (user) => {
this.render(user);
});
}
destroy() {
this.unsubscribe(); // Clean up!
}
}
2. Notification Storms
// BAD: Each property change triggers separate notification
store.firstName = 'John'; // notify!
store.lastName = 'Doe'; // notify!
store.email = 'j@d.com'; // notify!
// 3 separate notification cycles
// GOOD: Batch updates
class BatchableSubject extends Subject {
constructor() {
super();
this.batching = false;
this.pendingNotifications = [];
}
batch(fn) {
this.batching = true;
fn();
this.batching = false;
// Send ONE notification with all changes
this.notify({ changes: this.pendingNotifications });
this.pendingNotifications = [];
}
notify(data) {
if (this.batching) {
this.pendingNotifications.push(data);
} else {
super.notify(data);
}
}
}
3. Ordering Dependencies
// BAD: Observers depend on execution order
// Observer A sets a value that Observer B reads
// If order changes, system breaks
// GOOD: Use priority or explicit phases
class PrioritySubject extends Subject {
subscribe(observer, priority = 0) {
this.observers.push({ observer, priority });
this.observers.sort((a, b) => b.priority - a.priority);
}
notify(data) {
for (const { observer } of this.observers) {
observer.update(data);
}
}
}
Real-World Applications
| Application | Subject | Observers | Event |
|---|---|---|---|
| React | State/Props | Components | Re-render on change |
| Vue.js | Reactive data | Watchers/Computed | Data binding |
| Redux | Store | Connected components | dispatch(action) |
| Node.js | EventEmitter | Listeners | .emit() / .on() |
| WebSocket | Server | Connected clients | Message broadcast |
| MutationObserver | DOM node | Callback | DOM changes |
| Kafka | Producer | Consumer groups | Messages/Events |
Key Takeaways
- Observer enables one-to-many communication without the subject knowing concrete observer types
- JavaScript is built on this pattern -- DOM events, Node.js EventEmitter, Promises, and reactive frameworks all use it
- Always clean up subscriptions to prevent memory leaks (return unsubscribe functions)
- Error handling in notification loops is critical -- one bad observer should not break others
- Batch notifications when multiple properties change together to avoid unnecessary work
- Observer is synchronous by default in JavaScript; for async use Pub/Sub or message queues
Explain-It Challenge
You're building a multiplayer game server. Multiple systems need to react when a player performs an action (combat, chat, achievement, inventory). Explain how you would use the Observer pattern to decouple these systems. How would you handle: (a) a slow observer blocking others, (b) an observer that only cares about specific action types, (c) cleanup when a player disconnects?