Pub-Sub in Decoupled JavaScript Architectures

This article explores how the Publish-Subscribe (Pub/Sub) pattern is implemented to build decoupled, scalable JavaScript architectures. It covers the core mechanics of the pattern, provides a lightweight custom implementation, examines native JavaScript solutions in browser and Node.js environments, and highlights practical patterns for micro-frontends and modular systems.


Understanding the Pub/Sub Pattern

The Publish-Subscribe pattern is a messaging pattern where senders of messages (publishers) do not send messages directly to specific receivers (subscribers). Instead, messages are categorized into channels or topics without knowledge of which subscribers, if any, exist. Similarly, subscribers express interest in one or more topics and only receive messages that match those topics, without knowledge of the publishers.

In JavaScript architectures, this separation creates a decoupled system where modules operate independently, facilitating easier maintenance, testing, and scaling.

[ Publisher ] ──(emit event)──> [ Event Broker / Topic ] ──(notify)──> [ Subscriber A ]
                                                         ──(notify)──> [ Subscriber B ]

Core Implementation: Custom Pub/Sub Manager

A central event broker manages topics, listener registration, and message dispatch. Below is a standard, lightweight implementation in modern JavaScript (ES6+):

class PubSub {
  constructor() {
    this.events = new Map();
  }

  // Subscribe to a specific topic
  subscribe(topic, callback) {
    if (!this.events.has(topic)) {
      this.events.set(topic, new Set());
    }
    
    this.events.get(topic).add(callback);

    // Return an unsubscribe function for easy cleanup
    return () => {
      const callbacks = this.events.get(topic);
      if (callbacks) {
        callbacks.delete(callback);
        if (callbacks.size === 0) {
          this.events.delete(topic);
        }
      }
    };
  }

  // Publish data to all subscribers of a topic
  publish(topic, data) {
    const callbacks = this.events.get(topic);
    if (callbacks) {
      callbacks.forEach((callback) => {
        try {
          callback(data);
        } catch (error) {
          console.error(`Error executing subscriber for topic "${topic}":`, error);
        }
      });
    }
  }

  // Clear all subscribers for a topic or entire broker
  clear(topic) {
    if (topic) {
      this.events.delete(topic);
    } else {
      this.events.clear();
    }
  }
}

// Export as a singleton for shared application state
export const eventBus = new PubSub();

Practical Example: Decoupled Modules

Consider an e-commerce checkout flow where multiple services (UI, Analytics, Inventory) need to respond to an order completion without directly referencing each other:

// Module: Analytics Service (Subscriber)
import { eventBus } from './eventBus.js';

const unsubscribeAnalytics = eventBus.subscribe('order:placed', (order) => {
  console.log(`[Analytics] Logging order ${order.id} with value $${order.total}`);
});

// Module: Inventory Service (Subscriber)
eventBus.subscribe('order:placed', (order) => {
  console.log(`[Inventory] Reserving items for order ${order.id}`);
});

// Module: Checkout UI Component (Publisher)
function handleCheckoutSuccess(orderData) {
  // UI logic executes here...
  
  // Notify the system without importing Analytics or Inventory modules
  eventBus.publish('order:placed', {
    id: orderData.id,
    total: orderData.total,
    items: orderData.items
  });
}

Leveraging Native Platform APIs

JavaScript environments provide built-in event-driven constructs that can act as Pub/Sub mechanisms without third-party dependencies.

1. Browser: EventTarget and CustomEvent

Modern browsers expose the EventTarget interface, which can be instantiated directly as an event bus:

// Create a shared target
const bus = new EventTarget();

// Subscriber
bus.addEventListener('user:login', (event) => {
  console.log('User logged in:', event.detail);
});

// Publisher
bus.dispatchEvent(new CustomEvent('user:login', {
  detail: { userId: '12345', timestamp: Date.now() }
}));

2. Node.js: EventEmitter

In server-side JavaScript, the native events module provides an optimized Pub/Sub foundation:

import { EventEmitter } from 'events';

class AppEventBus extends EventEmitter {}
const serverBus = new AppEventBus();

// Subscriber
serverBus.on('file:processed', (payload) => {
  console.log(`File ${payload.fileName} processed successfully.`);
});

// Publisher
serverBus.emit('file:processed', { fileName: 'report.csv' });

Architectural Considerations and Best Practices