Skip to content

Implementing Event Bus Pattern in Vanilla JavaScript

I had a problem. Module A needed to notify Module B about state changes, but importing Module B into Module A created a circular dependency. Passing callbacks through three layers of function calls felt wrong. Global state? That’s a recipe for race conditions.

The Problem with Prop Drilling

I was building a vanilla JavaScript application with multiple independent modules:

  • Auth module - handles login/logout
  • Notification module - shows toast messages
  • Analytics module - tracks user actions
  • UI module - updates header and navigation

When a user logged in, I needed all these modules to react. But they had no direct relationship - no parent-child hierarchy. Prop drilling would mean passing the login event through functions that didn’t need it.

module-communication-problem.txt
Before Event Bus:
Auth Module --callback--> App --callback--> Header
--callback--> App --callback--> Analytics
--callback--> App --callback--> Notifications
This is spaghetti waiting to happen.

First Attempt: Extending EventTarget

I started with the simplest approach - extending the native EventTarget class:

event-bus-attempt-1.js
class EventBus extends EventTarget {
on(eventType, callback) {
this.addEventListener(eventType, callback);
}
off(eventType, callback) {
this.removeEventListener(eventType, callback);
}
emit(eventType, detail = {}) {
const event = new CustomEvent(eventType, { detail });
this.dispatchEvent(event);
}
}
const eventBus = new EventBus();
// Module A subscribes
eventBus.on('userLoggedIn', (e) => {
console.log('User:', e.detail.username);
});
// Module B publishes
eventBus.emit('userLoggedIn', { username: 'alice' });

This worked! The native EventTarget gives us browser-optimized event handling. No dependencies, battle-tested code.

But I ran into a problem: how do I unsubscribe when I don’t have a reference to the original callback?

unsubscribe-problem.js
// Anonymous function - can't unsubscribe!
eventBus.on('userLoggedIn', (e) => {
console.log('User:', e.detail.username);
});
// This won't work - it's a different function reference
eventBus.off('userLoggedIn', (e) => {
console.log('User:', e.detail.username);
});

Second Attempt: Custom Implementation with Unsubscribe

I needed more control. So I built a custom pub/sub system that returns an unsubscribe function:

event-bus-attempt-2.js
class EventBus {
constructor() {
this.events = {};
this.callbackId = 0;
}
subscribe(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = {};
}
const id = this.callbackId++;
this.events[eventName][id] = callback;
// Return unsubscribe function
return () => {
delete this.events[eventName][id];
if (Object.keys(this.events[eventName]).length === 0) {
delete this.events[eventName];
}
};
}
publish(eventName, ...args) {
const callbacks = this.events[eventName];
if (!callbacks) return;
for (const id in callbacks) {
callbacks[id](...args);
}
}
}
const bus = new EventBus();
// Now I can unsubscribe properly
const unsub = bus.subscribe('userLoggedIn', (username) => {
console.log('Welcome:', username);
});
bus.publish('userLoggedIn', 'alice'); // "Welcome: alice"
unsub(); // Clean up
bus.publish('userLoggedIn', 'bob'); // Nothing happens

This solved my memory leak problem. Each subscription returns a cleanup function.

Adding subscribeOnce Feature

Sometimes I only need to react once - like showing a welcome modal on first login:

subscribe-once.js
class EventBus {
constructor() {
this.events = {};
this.callbackId = 0;
}
subscribe(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = {};
}
const id = this.callbackId++;
this.events[eventName][id] = callback;
return () => {
delete this.events[eventName]?.[id];
if (Object.keys(this.events[eventName] || {}).length === 0) {
delete this.events[eventName];
}
};
}
subscribeOnce(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = {};
}
const id = 'once_' + this.callbackId++;
this.events[eventName][id] = callback;
return () => {
delete this.events[eventName]?.[id];
};
}
publish(eventName, ...args) {
const callbacks = this.events[eventName];
if (!callbacks) return;
for (const id in callbacks) {
callbacks[id](...args);
if (id.startsWith('once_')) {
delete callbacks[id];
}
}
}
clear(eventName) {
if (!eventName) {
this.events = {};
} else {
delete this.events[eventName];
}
}
// Debug helper
subscriberCount(eventName) {
return Object.keys(this.events[eventName] || {}).length;
}
}

Mistake I Made: Event Name Collisions

I started using generic event names like update and change. Then two different modules started firing update events, and subscribers got confused.

event-naming-mistake.js
// BAD: Generic names cause collisions
cartModule.emit('update', cartData);
profileModule.emit('update', profileData);
// Subscribers get confused
eventBus.on('update', (data) => {
// Is this cart or profile? Who knows!
});

The fix: namespace your events:

event-naming-fixed.js
// GOOD: Namespace events
eventBus.emit('cart:item:added', { productId: 123 });
eventBus.emit('user:profile:updated', { username: 'alice' });
eventBus.emit('auth:login:success', { userId: 456 });
// Clear intent in subscribers
eventBus.on('cart:item:added', (data) => {
updateCartUI(data);
});
eventBus.on('user:profile:updated', (data) => {
refreshProfile(data);
});

Mistake I Made: Memory Leaks

I forgot to unsubscribe when components were destroyed. The event bus held references to dead callbacks.

memory-leak-mistake.js
// Module that gets destroyed
class NotificationWidget {
constructor(eventBus) {
// BAD: No way to clean up
eventBus.on('notification:new', this.showNotification);
}
destroy() {
// Widget is destroyed but callback still exists in eventBus!
}
}

The fix - store and use unsubscribe:

memory-leak-fixed.js
class NotificationWidget {
constructor(eventBus) {
this.eventBus = eventBus;
this.subscriptions = [];
// Store unsubscribe function
const unsub = this.eventBus.on('notification:new', (data) => {
this.showNotification(data);
});
this.subscriptions.push(unsub);
}
destroy() {
// Clean up all subscriptions
this.subscriptions.forEach(unsub => unsub());
this.subscriptions = [];
}
showNotification(data) {
// Display notification
}
}

When Event Bus is Overkill

I got excited and started using event bus for everything. Bad idea. Parent-child communication? Direct function calls are clearer. Well-defined data flow? Props and state are better.

Event bus shines for:

  • Plugin/extension architectures
  • Cross-cutting concerns (logging, analytics)
  • WebSocket message distribution
  • Feature toggle notifications

Event bus is overkill for:

  • Simple parent-child communication
  • Well-defined data flow
  • When debugging is critical (event tracing is harder)

Final Implementation

Here’s my production-ready event bus with TypeScript support:

event-bus.ts
interface EventCallback {
[id: string]: Function;
}
interface EventRegistry {
[eventName: string]: EventCallback;
}
interface SubscriptionResult {
unsubscribe: () => void;
}
class EventBus {
private events: EventRegistry = {};
private idCounter: number = 0;
on(eventName: string, callback: Function): SubscriptionResult {
if (!this.events[eventName]) {
this.events[eventName] = {};
}
const id = String(this.idCounter++);
this.events[eventName][id] = callback;
return {
unsubscribe: () => {
delete this.events[eventName]?.[id];
if (Object.keys(this.events[eventName] || {}).length === 0) {
delete this.events[eventName];
}
}
};
}
once(eventName: string, callback: Function): SubscriptionResult {
if (!this.events[eventName]) {
this.events[eventName] = {};
}
const id = `once_${this.idCounter++}`;
this.events[eventName][id] = callback;
return {
unsubscribe: () => {
delete this.events[eventName]?.[id];
}
};
}
emit(eventName: string, ...args: any[]): void {
const callbacks = this.events[eventName];
if (!callbacks) return;
for (const id in callbacks) {
callbacks[id](...args);
if (id.startsWith('once_')) {
delete callbacks[id];
}
}
}
clear(eventName?: string): void {
if (!eventName) {
this.events = {};
} else {
delete this.events[eventName];
}
}
subscriberCount(eventName: string): number {
return Object.keys(this.events[eventName] || {}).length;
}
}
// Singleton for global use
const globalEventBus = new EventBus();
export { EventBus, globalEventBus };

Real-World Example: Authentication Flow

Here’s how I use it in my application:

auth-flow-example.js
import { globalEventBus as bus } from './event-bus.js';
// Auth module - the publisher
function handleLogin(username) {
// Perform login logic...
bus.emit('auth:login:success', {
username,
timestamp: Date.now()
});
}
// Notification module - independent subscriber
const notifSub = bus.on('auth:login:success', (data) => {
showToast(`Welcome back, ${data.username}!`);
});
// Analytics module - another independent subscriber
const analyticsSub = bus.on('auth:login:success', (data) => {
trackEvent('user_logged_in', data);
});
// UI module - updates header
const uiSub = bus.on('auth:login:success', (data) => {
updateHeaderUser(data.username);
});
// Cleanup when module is destroyed
function destroyUIModule() {
uiSub.unsubscribe();
}
// One-time welcome message
bus.once('auth:login:success', (data) => {
showWelcomeModal(data.username);
});

Each module is independent. They don’t import each other. They don’t know about each other. They just agree on event names.

Quick Comparison

ApproachProsConsBest For
EventTarget extensionNative, optimized, no dependenciesCan’t track subscribers, harder cleanupSimple cases, browser debugging
Custom pub/subFull control, easy cleanup, once supportMore code, need to maintainComplex apps, plugin systems
DOM documentZero code, global accessPollutes global namespace, no type safetyQuick prototypes, simple apps

The Gotcha I Almost Missed

I was about to ship when I noticed event names were case-sensitive:

case-sensitivity-mistake.js
bus.on('UserLoggedIn', handler);
bus.emit('userLoggedIn', data); // Handler NOT called!
// 'UserLoggedIn' and 'userLoggedIn' are DIFFERENT events

I standardized on lowercase with colons: module:action:detail.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments