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

ApplicationSubjectObserversEvent
ReactState/PropsComponentsRe-render on change
Vue.jsReactive dataWatchers/ComputedData binding
ReduxStoreConnected componentsdispatch(action)
Node.jsEventEmitterListeners.emit() / .on()
WebSocketServerConnected clientsMessage broadcast
MutationObserverDOM nodeCallbackDOM changes
KafkaProducerConsumer groupsMessages/Events

Key Takeaways

  1. Observer enables one-to-many communication without the subject knowing concrete observer types
  2. JavaScript is built on this pattern -- DOM events, Node.js EventEmitter, Promises, and reactive frameworks all use it
  3. Always clean up subscriptions to prevent memory leaks (return unsubscribe functions)
  4. Error handling in notification loops is critical -- one bad observer should not break others
  5. Batch notifications when multiple properties change together to avoid unnecessary work
  6. 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?