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 components by allowing them to communicate through an event bus without knowing about each other. As the creator of CoreUI with over 25 years of JavaScript experience since 2000, I’ve used pub/sub for cross-component communication, plugin systems, and loosely coupled module architectures. The standard implementation stores subscriber functions in a map keyed by event name and calls them when events are published. This eliminates direct dependencies between modules.

Implement a basic pub/sub event emitter.

class EventBus {
  constructor() {
    this.subscribers = {}
  }

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

    return () => this.unsubscribe(event, callback)
  }

  unsubscribe(event, callback) {
    if (this.subscribers[event]) {
      this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
    }
  }

  publish(event, data) {
    if (this.subscribers[event]) {
      this.subscribers[event].forEach(callback => callback(data))
    }
  }
}

const bus = new EventBus()

const unsubscribe = bus.subscribe('user:login', (user) => {
  console.log('User logged in:', user.name)
})

bus.publish('user:login', { name: 'Alice', id: 1 })

unsubscribe()

subscribe adds a callback and returns an unsubscribe function. publish calls all callbacks for the event. Returning the cleanup function enables easy removal without storing the reference externally.

Wildcard and Namespaced Events

Support event namespaces for organized subscriptions.

class EventBus {
  constructor() {
    this.subscribers = new Map()
  }

  on(event, callback) {
    if (!this.subscribers.has(event)) {
      this.subscribers.set(event, new Set())
    }
    this.subscribers.get(event).add(callback)
    return () => this.off(event, callback)
  }

  off(event, callback) {
    this.subscribers.get(event)?.delete(callback)
  }

  emit(event, data) {
    this.subscribers.get(event)?.forEach(cb => cb(data))

    const namespace = event.split(':')[0]
    if (namespace !== event) {
      this.subscribers.get(`${namespace}:*`)?.forEach(cb => cb(data, event))
    }
  }
}

const bus = new EventBus()

bus.on('cart:*', (data, event) => {
  console.log(`Cart event [${event}]:`, data)
})

bus.emit('cart:add', { productId: 1 })
bus.emit('cart:remove', { productId: 1 })
bus.emit('cart:clear', {})

Using a Map and Set ensures unique subscribers and faster lookups. Namespace wildcards like cart:* catch all events in a domain. This is useful for logging or analytics.

Once Subscriptions

Subscribe to an event that fires only one time.

class EventBus {
  constructor() {
    this.subscribers = {}
  }

  on(event, callback) {
    if (!this.subscribers[event]) this.subscribers[event] = []
    this.subscribers[event].push(callback)
    return () => this.off(event, callback)
  }

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

  off(event, callback) {
    if (this.subscribers[event]) {
      this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
    }
  }

  emit(event, data) {
    this.subscribers[event]?.forEach(cb => cb(data))
  }
}

const bus = new EventBus()

bus.once('app:ready', () => {
  console.log('App initialized - this fires only once')
})

bus.emit('app:ready')
bus.emit('app:ready') // Second emit does nothing

once wraps the callback to self-unsubscribe after first call. This is useful for one-time initialization events. The wrapper is stored internally so cleanup works correctly.

Using as Global Singleton

Share a single bus instance across modules.

// eventBus.js
class EventBus {
  constructor() {
    this.subscribers = {}
  }

  on(event, callback) {
    if (!this.subscribers[event]) this.subscribers[event] = []
    this.subscribers[event].push(callback)
    return () => this.off(event, callback)
  }

  off(event, callback) {
    if (this.subscribers[event]) {
      this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback)
    }
  }

  emit(event, data) {
    this.subscribers[event]?.forEach(cb => cb(data))
  }
}

export default new EventBus()
// moduleA.js
import bus from './eventBus.js'
bus.emit('data:loaded', { count: 42 })

// moduleB.js
import bus from './eventBus.js'
bus.on('data:loaded', ({ count }) => {
  console.log('Received count:', count)
})

Exporting a single instance creates a natural singleton via ES6 modules. Both modules import the same bus object. This is the simplest pattern for applications that don’t use a framework’s state management.

Best Practice Note

This is the same pub/sub pattern we use in CoreUI for cross-component communication. Always unsubscribe in cleanup functions - leaked subscriptions cause memory leaks and unexpected behavior. Use namespaced event names like module:action to avoid collisions. Document all event names and their payloads as they become an implicit API contract. For complex applications with React, Vue, or Angular, prefer their built-in state management solutions (Context, Pinia, NgRx) which are better tooled. Use pub/sub for lightweight communication or when integrating non-framework code.


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