Building an Event Bus with JS Pub Sub Pattern

Build a practical event bus using the publish-subscribe pattern in JavaScript. Learn how pub-sub decouples components, enables modular communication, and keeps your codebase clean.

8 min read

The publish-subscribe pattern lets parts of your application communicate without knowing about each other. One piece of code publishes an event. Other pieces subscribe to that event. Neither side holds a direct reference to the other.

Think of it like a radio station. The station broadcasts music. Your radio receives it. The station does not know you exist. Your radio does not know where the station is. They communicate through a shared channel.

In code, that shared channel is the event bus.

Pub-sub event bus decoupling publishers and subscribers

Publishers and subscribers only know about the event bus. They do not know about each other. This is the core benefit: you can add or remove subscribers without changing the publisher, and vice versa.

Building the Event Bus

Start with the simplest possible event bus: a plain object that maps event names to arrays of callbacks.

javascriptjavascript
function createEventBus() {
  const listeners = {};
 
  return {
    on(event, callback) {
      if (!listeners[event]) {
        listeners[event] = [];
      }
      listeners[event].push(callback);
    },
 
    emit(event, data) {
      const callbacks = listeners[event];
      if (!callbacks) return;
      callbacks.forEach(cb => cb(data));
    }
  };
}

That is the core. on registers a listener. emit calls every listener registered for that event. Here is what it looks like in use:

javascriptjavascript
const bus = createEventBus();
 
bus.on("order:placed", (order) => {
  console.log(`Inventory: deducted ${order.quantity} units`);
});
 
bus.on("order:placed", (order) => {
  console.log(`Email: confirmation sent to ${order.email}`);
});
 
bus.emit("order:placed", { id: 4001, quantity: 2, email: "alice@example.com" });
texttext
Inventory: deducted 2 units
Email: confirmation sent to alice@example.com

One emit call triggered both subscribers. Neither subscriber knows the other exists. Neither knows what triggered the event. They only know the event name and the data they receive.

Adding Unsubscribe

The bus is missing one critical feature: removing listeners. Without it, callbacks accumulate forever, causing memory leaks in long-running applications.

javascriptjavascript
function createEventBus() {
  const listeners = {};
 
  return {
    on(event, callback) {
      if (!listeners[event]) {
        listeners[event] = [];
      }
      listeners[event].push(callback);
 
      // Return an unsubscribe function
      return () => {
        listeners[event] = listeners[event].filter(cb => cb !== callback);
      };
    },
 
    emit(event, data) {
      const callbacks = listeners[event];
      if (!callbacks) return;
      callbacks.forEach(cb => cb(data));
    }
  };
}

Now on returns a function. Call that function to unsubscribe:

javascriptjavascript
const bus = createEventBus();
 
function handleClick(data) {
  console.log("Clicked:", data);
}
 
const unsub = bus.on("click", handleClick);
bus.emit("click", "button-1"); // Clicked: button-1
 
unsub();
bus.emit("click", "button-2"); // Nothing logged -- already unsubscribed

A Complete Event Bus with Error Handling

Here is a production-ready version with once (auto-unsubscribe after first emission) and error isolation so one broken subscriber does not crash others:

javascriptjavascript
function createEventBus() {
  const listeners = {};
 
  function getCallbacks(event) {
    if (!listeners[event]) {
      listeners[event] = [];
    }
    return listeners[event];
  }
 
  return {
    on(event, callback) {
      getCallbacks(event).push(callback);
      return () => {
        listeners[event] = listeners[event].filter(cb => cb !== callback);
      };
    },
 
    once(event, callback) {
      const wrapper = (data) => {
        callback(data);
        listeners[event] = listeners[event].filter(cb => cb !== wrapper);
      };
      getCallbacks(event).push(wrapper);
    },
 
    emit(event, data) {
      const callbacks = listeners[event];
      if (!callbacks) return;
 
      callbacks.forEach(cb => {
        try {
          cb(data);
        } catch (err) {
          console.error(`Error in subscriber for "${event}":`, err);
        }
      });
    },
 
    clear(event) {
      if (event) {
        delete listeners[event];
      } else {
        Object.keys(listeners).forEach(key => delete listeners[key]);
      }
    }
  };
}

The once method wraps the callback so it auto-removes after the first call. The clear method allows bulk cleanup. Error isolation keeps one bad subscriber from breaking the entire bus.

Real Use Case: Connecting UI Components

Here is a practical example. Two independent UI components communicate through the event bus without importing each other:

javascriptjavascript
const bus = createEventBus();
 
// Component A: a search box that publishes search events
function SearchBox() {
  const input = document.querySelector("#search-input");
  input.addEventListener("input", (e) => {
    bus.emit("search:changed", e.target.value);
  });
}
 
// Component B: a results panel that subscribes to search events
function ResultsPanel() {
  const panel = document.querySelector("#results-panel");
 
  bus.on("search:changed", (query) => {
    if (query.length < 2) {
      panel.textContent = "Type at least 2 characters";
      return;
    }
    panel.textContent = `Searching for: ${query}`;
  });
}
 
// Component C: an analytics tracker also listening
function AnalyticsTracker() {
  bus.on("search:changed", (query) => {
    if (query.length >= 3) {
      console.log(`Analytics: user searched "${query}"`);
    }
  });
}
 
SearchBox();
ResultsPanel();
AnalyticsTracker();

Each component is self-contained. SearchBox does not import ResultsPanel or AnalyticsTracker. They all only import the shared bus. You can add a fourth listener (logging, keyboard shortcuts, autocomplete) without touching any existing component.

This pattern is the foundation of how frameworks like building a reactive UI handle state propagation internally.

Common Pitfalls

Memory leaks from forgotten unsubscriptions. Every on call should have a matching cleanup call. If a component subscribes on mount, it must unsubscribe on unmount. This is the most common pub-sub bug in the browser.

Event name collisions. Use namespaced event names like user:login and user:logout rather than flat names like login. This prevents two unrelated parts of the app from accidentally using the same event name.

Ordering assumptions. Never assume subscribers fire in subscription order. The implementation iterates the array in order, but callers should not depend on this. If two subscribers need a specific order, they should communicate through a separate mechanism.

When Pub-Sub Makes Sense

The pattern is useful when:

  • You have independent modules that need to react to the same events.
  • You are building a plugin system where third-party code needs to hook into your app.
  • Components live in different parts of the DOM tree and do not share a parent.
  • You are building real-time features where multiple views update from a single data source.

Skip pub-sub when:

  • Two components have a direct parent-child relationship. Use callback functions or direct method calls instead.
  • The data flow is one-directional and simple. An event bus adds indirection that makes code harder to trace.
  • You only have one publisher and one subscriber. A direct function call is clearer.
Rune AI

Rune AI

Key Insights

  • Pub-sub decouples publishers from subscribers through a shared event bus.
  • Publishers emit events by name and subscribers listen for specific event names.
  • Always provide an unsubscribe mechanism and clean up listeners.
  • The event bus is useful for cross-component communication in UI apps.
  • It is the foundation of most state management and real-time systems.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between pub-sub and the Observer pattern?

In the observer pattern, the subject maintains a list of its observers and notifies them directly. In pub-sub, publishers and subscribers do not know about each other. They communicate through an event bus or message broker that routes events.

Does pub-sub cause memory leaks?

If subscribers are not removed when they are no longer needed, their callback references remain in the event bus, preventing garbage collection. Always unsubscribe when a component unmounts or is destroyed.

Is this the same as Node.js EventEmitter?

The pattern is the same. Node.js has a built-in EventEmitter class. The implementation in this article shows how to build your own so you understand the internals.

Conclusion

The pub-sub pattern is one of the most practical patterns in JavaScript. Build an event bus, subscribe to the events you care about, publish when things happen, and unsubscribe when you are done. The decoupling it provides is worth the small implementation cost.Pub-sub is the simplest way to decouple communication in JavaScript. Build a bus object that maps event names to callback arrays. Give it on, emit, and an unsubscribe mechanism. Use namespaced event names. Clean up subscriptions when they are no longer needed. The pattern appears everywhere, from the browser's built-in addEventListener to Node.js EventEmitter to modern state management libraries. Understanding how to build one yourself makes all of those tools easier to reason about.