Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to implement pub/sub pattern in JavaScript

The publish-subscribe pattern decouples communication between components by allowing publishers to emit events without knowing who subscribes, and subscribers to listen for events without knowing who publishes. As the creator of CoreUI with 26 years of JavaScript development experience, I’ve implemented pub/sub systems in applications serving millions of users, enabling loosely coupled architectures that scale efficiently across distributed features.

The most maintainable approach uses a central event bus with typed event channels.

Basic Pub/Sub Implementation

class PubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push(callback)

    // Return unsubscribe function
    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }

  publish(event, data) {
    if (!this.events[event]) {
      return
    }

    this.events[event].forEach(callback => callback(data))
  }

  clear(event) {
    if (event) {
      delete this.events[event]
    } else {
      this.events = {}
    }
  }
}

// Usage
const pubsub = new PubSub()

// Subscribe to events
const unsubscribe = pubsub.subscribe('user:login', (user) => {
  console.log('User logged in:', user.name)
})

// Publish events
pubsub.publish('user:login', { name: 'John', id: 1 })

// Unsubscribe
unsubscribe()

Singleton Event Bus

const EventBus = (() => {
  const events = {}

  return {
    on(event, callback) {
      if (!events[event]) {
        events[event] = []
      }
      events[event].push(callback)
    },

    off(event, callback) {
      if (!events[event]) return

      events[event] = events[event].filter(cb => cb !== callback)
    },

    once(event, callback) {
      const wrapper = (data) => {
        callback(data)
        this.off(event, wrapper)
      }
      this.on(event, wrapper)
    },

    emit(event, data) {
      if (!events[event]) return

      // Create copy to avoid issues if callbacks modify subscribers
      [...events[event]].forEach(callback => {
        try {
          callback(data)
        } catch (error) {
          console.error(`Error in ${event} subscriber:`, error)
        }
      })
    },

    clear() {
      Object.keys(events).forEach(event => {
        events[event] = []
      })
    }
  }
})()

// Usage
EventBus.on('cart:add', (product) => {
  console.log('Added to cart:', product.name)
})

EventBus.once('cart:checkout', (order) => {
  console.log('Checkout initiated:', order.id)
  // Only fires once
})

EventBus.emit('cart:add', { name: 'Laptop', price: 999 })
EventBus.emit('cart:checkout', { id: 123, total: 999 })

Namespaced Events

class NamespacedPubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback) {
    // Support namespaced events: 'user:login', 'user:logout'
    const parts = event.split(':')
    const namespace = parts[0]
    const eventName = parts[1] || '*'

    if (!this.events[namespace]) {
      this.events[namespace] = {}
    }

    if (!this.events[namespace][eventName]) {
      this.events[namespace][eventName] = []
    }

    this.events[namespace][eventName].push(callback)

    return () => {
      this.events[namespace][eventName] = this.events[namespace][eventName].filter(
        cb => cb !== callback
      )
    }
  }

  publish(event, data) {
    const parts = event.split(':')
    const namespace = parts[0]
    const eventName = parts[1]

    if (!this.events[namespace]) return

    // Call specific event subscribers
    if (this.events[namespace][eventName]) {
      this.events[namespace][eventName].forEach(cb => cb(data))
    }

    // Call wildcard subscribers (namespace:*)
    if (this.events[namespace]['*']) {
      this.events[namespace]['*'].forEach(cb => cb({ event: eventName, data }))
    }
  }

  clearNamespace(namespace) {
    delete this.events[namespace]
  }
}

// Usage
const pubsub = new NamespacedPubSub()

// Subscribe to specific event
pubsub.subscribe('user:login', (user) => {
  console.log('Login:', user.name)
})

// Subscribe to all user events
pubsub.subscribe('user:*', ({ event, data }) => {
  console.log(`User event: ${event}`, data)
})

pubsub.publish('user:login', { name: 'John' })
pubsub.publish('user:logout', { name: 'John' })

Priority Subscribers

class PriorityPubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback, priority = 0) {
    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push({ callback, priority })

    // Sort by priority (higher priority first)
    this.events[event].sort((a, b) => b.priority - a.priority)

    return () => {
      this.events[event] = this.events[event].filter(
        sub => sub.callback !== callback
      )
    }
  }

  publish(event, data) {
    if (!this.events[event]) return

    for (const subscriber of this.events[event]) {
      subscriber.callback(data)
    }
  }
}

// Usage
const pubsub = new PriorityPubSub()

// Low priority
pubsub.subscribe('data:save', (data) => {
  console.log('3. Update UI')
}, 0)

// High priority (runs first)
pubsub.subscribe('data:save', (data) => {
  console.log('1. Validate data')
}, 100)

// Medium priority
pubsub.subscribe('data:save', (data) => {
  console.log('2. Save to server')
}, 50)

pubsub.publish('data:save', { id: 1 })
// Output:
// 1. Validate data
// 2. Save to server
// 3. Update UI

Async Pub/Sub

class AsyncPubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback) {
    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push(callback)

    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }

  async publish(event, data) {
    if (!this.events[event]) return

    const results = []

    for (const callback of this.events[event]) {
      try {
        const result = await callback(data)
        results.push({ success: true, result })
      } catch (error) {
        results.push({ success: false, error })
      }
    }

    return results
  }

  async publishParallel(event, data) {
    if (!this.events[event]) return []

    const promises = this.events[event].map(callback =>
      Promise.resolve(callback(data))
        .then(result => ({ success: true, result }))
        .catch(error => ({ success: false, error }))
    )

    return Promise.all(promises)
  }
}

// Usage
const pubsub = new AsyncPubSub()

pubsub.subscribe('user:create', async (user) => {
  await saveToDatabase(user)
  return { saved: true }
})

pubsub.subscribe('user:create', async (user) => {
  await sendWelcomeEmail(user)
  return { emailSent: true }
})

// Sequential execution
const results = await pubsub.publish('user:create', {
  name: 'John',
  email: '[email protected]'
})

// Parallel execution
const parallelResults = await pubsub.publishParallel('user:create', {
  name: 'Jane',
  email: '[email protected]'
})

Event History and Replay

class PubSubWithHistory {
  constructor(maxHistory = 100) {
    this.events = {}
    this.history = {}
    this.maxHistory = maxHistory
  }

  subscribe(event, callback, replayHistory = false) {
    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push(callback)

    // Replay historical events if requested
    if (replayHistory && this.history[event]) {
      this.history[event].forEach(data => callback(data))
    }

    return () => {
      this.events[event] = this.events[event].filter(cb => cb !== callback)
    }
  }

  publish(event, data) {
    // Store in history
    if (!this.history[event]) {
      this.history[event] = []
    }

    this.history[event].push(data)

    // Limit history size
    if (this.history[event].length > this.maxHistory) {
      this.history[event].shift()
    }

    // Publish to subscribers
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data))
    }
  }

  getHistory(event) {
    return this.history[event] || []
  }

  clearHistory(event) {
    if (event) {
      delete this.history[event]
    } else {
      this.history = {}
    }
  }
}

// Usage
const pubsub = new PubSubWithHistory(50)

pubsub.publish('chat:message', { text: 'Hello', user: 'John' })
pubsub.publish('chat:message', { text: 'Hi there', user: 'Jane' })

// New subscriber gets message history
pubsub.subscribe('chat:message', (msg) => {
  console.log(`${msg.user}: ${msg.text}`)
}, true) // replayHistory = true

// Output:
// John: Hello
// Jane: Hi there

Filtered Subscriptions

class FilteredPubSub {
  constructor() {
    this.events = {}
  }

  subscribe(event, callback, filter = null) {
    if (!this.events[event]) {
      this.events[event] = []
    }

    this.events[event].push({ callback, filter })

    return () => {
      this.events[event] = this.events[event].filter(
        sub => sub.callback !== callback
      )
    }
  }

  publish(event, data) {
    if (!this.events[event]) return

    this.events[event].forEach(({ callback, filter }) => {
      if (!filter || filter(data)) {
        callback(data)
      }
    })
  }
}

// Usage
const pubsub = new FilteredPubSub()

// Only receive messages from specific user
pubsub.subscribe(
  'chat:message',
  (msg) => console.log('Admin message:', msg.text),
  (msg) => msg.user === 'admin'
)

// Only receive high priority notifications
pubsub.subscribe(
  'notification',
  (notif) => console.log('URGENT:', notif.text),
  (notif) => notif.priority === 'high'
)

pubsub.publish('chat:message', { user: 'admin', text: 'System update' })
// Output: Admin message: System update

pubsub.publish('chat:message', { user: 'john', text: 'Hello' })
// No output (filtered out)

pubsub.publish('notification', { priority: 'high', text: 'Server down' })
// Output: URGENT: Server down

Best Practice Note

This is how we implement event-driven communication in CoreUI applications for loosely coupled architecture. The pub/sub pattern decouples publishers from subscribers, enabling components to communicate without direct dependencies. Always implement unsubscribe functionality to prevent memory leaks, use namespaces to organize events, handle errors gracefully in subscribers to prevent one broken subscriber from affecting others, and consider async execution for I/O-heavy operations. For complex applications, add features like priority, filtering, and history replay.

For production applications, consider using CoreUI’s React Admin Template which includes event bus utilities for component communication.

For related patterns, check out how to implement observer pattern in JavaScript and how to implement event emitter in JavaScript.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author

Subscribe to our newsletter
Get early information about new products, product updates and blog posts.

Answers by CoreUI Core Team