All Snippets Snippet JavaScript

Lightweight Event Bus

A pub-sub system for decoupled component communication with auto-unsubscribe and error isolation.

D
Kunwar "AKA" AJ Sharing what I have learned
Feb 21, 2026 3 min JavaScript

The Pattern

eventBus.js
class EventBus {
    constructor() {
        this.listeners = new Map();
    }

    on(event, callback) {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, []);
        }
        this.listeners.get(event).push(callback);

        // Return unsubscribe function
        return () => {
            const callbacks = this.listeners.get(event);
            const index = callbacks.indexOf(callback);
            if (index > -1) callbacks.splice(index, 1);
        };
    }

    emit(event, data) {
        const callbacks = this.listeners.get(event) || [];
        for (const callback of callbacks) {
            try {
                callback(data);
            } catch (err) {
                console.error(`EventBus: listener error on "${event}"`, err);
            }
        }
    }

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

What Happens Under the Hood

Let us trace what happens when two components communicate through the event bus:

execution-flow.txt
Setup:
  CartUI.on("item:added")       → listeners Map: { "item:added": [CartUI.update] }
  Analytics.on("item:added")    → listeners Map: { "item:added": [CartUI.update, Analytics.track] }
  Notification.once("item:added") → listeners Map: { "item:added": [CartUI.update, Analytics.track, Notification.show] }

First emit("item:added", { id: 42, name: "Widget" }):
  → CartUI.update({ id: 42, name: "Widget" })      ✓ stays subscribed
  → Analytics.track({ id: 42, name: "Widget" })     ✓ stays subscribed
  → Notification.show({ id: 42, name: "Widget" })   ✓ auto-unsubscribed (once)

Second emit("item:added", { id: 99, name: "Gadget" }):
  → CartUI.update({ id: 99, name: "Gadget" })       ✓ still listening
  → Analytics.track({ id: 99, name: "Gadget" })      ✓ still listening
  → Notification: not called (already removed)

Three design choices make this reliable. First, the try/catch around each callback prevents one broken listener from killing all others. Second, on() returns an unsubscribe function, so cleanup is a simple call rather than manual bookkeeping. Third, once() builds on on() by auto-removing the listener after one call, which prevents memory leaks from forgotten one-time subscriptions.

Why This Matters

coupling.txt
Without event bus (tight coupling):
  ProductPage → calls CartUI.update()
  ProductPage → calls Analytics.track()
  ProductPage → calls Notification.show()
  ProductPage knows about EVERY consumer — hard to add new ones

With event bus (loose coupling):
  ProductPage → emits "item:added"
  CartUI, Analytics, Notification subscribe independently
  ProductPage knows about NOBODY — add new consumers freely

Usage Example

usage.js
const bus = new EventBus();

// Component A: user authentication
function onLoginSuccess(user) {
    bus.emit('auth:login', { userId: user.id, role: user.role });
}

// Component B: update navigation (stays subscribed)
bus.on('auth:login', ({ userId, role }) => {
    renderNav(role === 'admin' ? adminMenu : userMenu);
});

// Component C: one-time welcome message
bus.once('auth:login', ({ userId }) => {
    showWelcomeBanner(userId);
});

// Component D: cleanup on unmount
const unsubscribe = bus.on('auth:login', syncUserPreferences);
// Later, when component is destroyed:
unsubscribe();

Use an event bus when components need to communicate without knowing about each other. It is ideal for cross-cutting concerns like authentication state, shopping cart updates, and notification systems. For small applications, a single global bus works well. For larger systems, consider scoped buses per feature area to keep event names from colliding.