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.
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:
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 subscribeseventBus.on('userLoggedIn', (e) => { console.log('User:', e.detail.username);});
// Module B publisheseventBus.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?
// Anonymous function - can't unsubscribe!eventBus.on('userLoggedIn', (e) => { console.log('User:', e.detail.username);});
// This won't work - it's a different function referenceeventBus.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:
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 properlyconst unsub = bus.subscribe('userLoggedIn', (username) => { console.log('Welcome:', username);});
bus.publish('userLoggedIn', 'alice'); // "Welcome: alice"
unsub(); // Clean up
bus.publish('userLoggedIn', 'bob'); // Nothing happensThis 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:
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.
// BAD: Generic names cause collisionscartModule.emit('update', cartData);profileModule.emit('update', profileData);
// Subscribers get confusedeventBus.on('update', (data) => { // Is this cart or profile? Who knows!});The fix: namespace your events:
// GOOD: Namespace eventseventBus.emit('cart:item:added', { productId: 123 });eventBus.emit('user:profile:updated', { username: 'alice' });eventBus.emit('auth:login:success', { userId: 456 });
// Clear intent in subscriberseventBus.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.
// Module that gets destroyedclass 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:
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:
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 useconst globalEventBus = new EventBus();
export { EventBus, globalEventBus };Real-World Example: Authentication Flow
Here’s how I use it in my application:
import { globalEventBus as bus } from './event-bus.js';
// Auth module - the publisherfunction handleLogin(username) { // Perform login logic... bus.emit('auth:login:success', { username, timestamp: Date.now() });}
// Notification module - independent subscriberconst notifSub = bus.on('auth:login:success', (data) => { showToast(`Welcome back, ${data.username}!`);});
// Analytics module - another independent subscriberconst analyticsSub = bus.on('auth:login:success', (data) => { trackEvent('user_logged_in', data);});
// UI module - updates headerconst uiSub = bus.on('auth:login:success', (data) => { updateHeaderUser(data.username);});
// Cleanup when module is destroyedfunction destroyUIModule() { uiSub.unsubscribe();}
// One-time welcome messagebus.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
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| EventTarget extension | Native, optimized, no dependencies | Can’t track subscribers, harder cleanup | Simple cases, browser debugging |
| Custom pub/sub | Full control, easy cleanup, once support | More code, need to maintain | Complex apps, plugin systems |
| DOM document | Zero code, global access | Pollutes global namespace, no type safety | Quick prototypes, simple apps |
The Gotcha I Almost Missed
I was about to ship when I noticed event names were case-sensitive:
bus.on('UserLoggedIn', handler);bus.emit('userLoggedIn', data); // Handler NOT called!
// 'UserLoggedIn' and 'userLoggedIn' are DIFFERENT eventsI 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:
- 👨💻 MDN Web Docs: EventTarget
- 👨💻 MDN Web Docs: CustomEvent
- 👨💻 JavaScript Patterns by Stoyan Stefanov
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments