Gigson Expert

/

March 10, 2026

Event Buses Explained: The Message Board Your Project Has Been Missing

Learn how event buses decouple your application logic and prevent bloated functions. Discover how the event-driven pattern keeps your code modular, scalable, and maintainable.

Blog Image

Nwaokocha Michael

A Web Developer with experience building scalable web and mobile applications. He works with React, TypeScript, Golang, and Node.js, and enjoys writing about the patterns that make software easier to understand. When not debugging distributed systems or writing articles, you can find him reading sci-fi novels or hiking.

Article by Gigson Expert

Here's a situation I've seen way too many times: you're building a feature where users can register for your app. Simple enough. They submit a form, you create their account in the database, done.

Then marketing asks you to send a welcome email. Okay, add that to the registration function.

Then the analytics team wants to track new signups. Add that too.

Then you need to create a default profile for them. Add it.

Then someone wants to notify the sales team for certain types of users. Add it.

Before you know it, your simple registration function has become a 200-line monster that does fifteen different things. And every time one of those things breaks or needs to change, you're touching this critical piece of code that handles user registration. One bad deploy and nobody can sign up.

Worse, each of those features is now tightly coupled to your registration logic. Want to send emails from somewhere else? Too bad, that code is buried in the registration function. Want to test registration without triggering all the side effects? Good luck.

This is the problem event buses solve. And once you understand the pattern, you'll start seeing places to use it everywhere.

The Bulletin Board Analogy

To understand Event Buses, consider an office analogy.

Option 1 (Slow): The boss tells 50 employees individually about free pizza. This is slow and requires the boss to know and track every employee.

Option 2 (Efficient): The boss posts "Free pizza in the break room!" on a bulletin board. Interested employees check the board and react. The boss (event publisher) emits a message (event) without knowing or caring who reads it. Employees (event subscribers) handle the event.

An event bus is this bulletin board for your app. This pattern solves the fundamental problem of broadcasting information to interested parties without the broadcaster needing to know the recipients.

What Is an Event Bus?

An event bus is a pattern (and often a piece of code) that lets different parts of your application communicate without being directly connected.

There are three key concepts:

Events are things that happen in your system and are facts about the past (e.g., "User registered," "order placed," "payment completed," "file uploaded"). Other parts of the system may want to know about them.

Publishers (or emitters) are the parts of your code that announce when events happen. They don't know or care who's listening.

Subscribers (or listeners) are the parts of your code that care about specific events. They register their interest upfront, get notified when events happen, and then react.

The event bus sits in the middle, connecting publishers and subscribers without them knowing about each other.

Here's what that looks like conceptually:

Publisher                Event Bus              Subscribers
   |                         |                      |
   |--- "User Registered" -->|---> Email Service ---|
   |                         |---> Analytics -------|
   |                         |---> Profile Creator -|
   |                         |---> Sales Notifier --|

The publisher just emits the event. The event bus handles distribution to all interested subscribers. Clean, decoupled, flexible.

Building a Simple Event Bus

Let's build one. The simplest version is surprisingly small:

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

  // Subscribe: "Call me when this event happens"
  on(eventName, callback) {
    if (!this.subscribers[eventName]) {
      this.subscribers[eventName] = [];
    }
    this.subscribers[eventName].push(callback);
  }

  // Emit: "This event just happened, notify everyone who cares"
  // Note: In production, wrap callbacks in try/catch to isolate failures
  emit(eventName, data) {
    if (!this.subscribers[eventName]) {
      return; // Nobody's listening, that's fine
    }
    
    this.subscribers[eventName].forEach(callback => {
      callback(data);
    });
  }

  // Unsubscribe: "Stop calling me about this"
  off(eventName, callback) {
    if (!this.subscribers[eventName]) {
      return;
    }
    
    this.subscribers[eventName] = this.subscribers[eventName]
      .filter(cb => cb !== callback);
  }
}

// Create a shared event bus
const eventBus = new EventBus();

That's it. That's the core pattern. Let's see it in action:

// Different parts of your app subscribe to events they care about

// Email service listens for user registration
eventBus.on('user:registered', (user) => {
  console.log(`Sending welcome email to ${user.email}`);
  // sendWelcomeEmail(user.email);
});

// Analytics service listens for the same event
eventBus.on('user:registered', (user) => {
  console.log(`Tracking signup for user ${user.id}`);
  // analytics.track('User Signed Up', user);
});

// Profile service listens too
eventBus.on('user:registered', (user) => {
  console.log(`Creating default profile for ${user.id}`);
  // createDefaultProfile(user.id);
});

// Now when user registration happens, just emit the event
function registerUser(email, password) {
  // Do the actual registration
  const user = { 
    id: generateId(), 
    email, 
    createdAt: new Date() 
  };
  
  saveToDatabase(user);
  
  // Announce what happened
  eventBus.emit('user:registered', user);
  
  // That's it! We're done. Everything else happens automatically.
  return user;
}

See how clean that is? The registration function does one thing: registers the user. It doesn't know about emails, analytics, or profiles. It just announces "user registered" and moves on.

All the other stuff happens automatically because those services are subscribed to that event. If you want to add a new feature that triggers on registration, you don't touch the registration code at all. You just add another subscriber:

// New requirement: notify sales team for enterprise signups
eventBus.on('user:registered', (user) => {
  if (user.email.endsWith('@bigcompany.com')) {
    console.log(`Notifying sales team about enterprise signup: ${user.email}`);
    // notifySalesTeam(user);
  }
});

No changes to existing code. No risk of breaking registration. Just add a listener, and you're done.

Why This Matters

Decoupling:

Features like email, analytics, and profiles are independent. The registration code doesn't need to know about its subscribers. Changes to one subscriber won't affect others or the publisher.

Flexibility:

Easily add new features (subscribers) that react to an event without changing the core event-emitting logic. Features can be temporarily disabled or conditionally run.

Testability:

Testing can be simplified by not registering side-effecting subscribers (like email or analytics) or by registering mock subscribers to verify correct calls.

Reusability:

Events can be reused across different parts of the application. The event bus handles different events ("user:registered," "user:login") using the same simple pattern.

Clarity:

The list of subscribers provides a clear, self-documenting list of "what happens when this event occurs," which is clearer than a single large function with multiple side effects.

Scaling Up (And Down)

Here's the beautiful thing about event buses: the core logic stays the same whether you're building a small app running on one server or a distributed system spanning multiple services.

In-memory (what we just built): Perfect for monolithic applications where everything runs in the same process. Fast, simple, no infrastructure needed. Events are just function calls.

HTTP-based: For distributed systems, the pattern is identical, but the implementation changes. Instead of storing callbacks in memory, subscribers register webhook URLs. When an event happens, the event bus makes HTTP requests to notify subscribers:

// Conceptually the same, different transport
class DistributedEventBus {
  constructor() {
    this.webhooks = {}; // { eventName: [url1, url2, ...] }
  }

  // Subscribe by registering a webhook URL
  on(eventName, webhookUrl) {
    if (!this.webhooks[eventName]) {
      this.webhooks[eventName] = [];
    }
    this.webhooks[eventName].push(webhookUrl);
  }

  // Emit by calling all registered webhooks
  async emit(eventName, data) {
    if (!this.webhooks[eventName]) return;
    
    const notifications = this.webhooks[eventName].map(url =>
      fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ event: eventName, data })
      })
    );
    
    await Promise.all(notifications);
  }
}

Same on and emit pattern. Same logic. Different transport mechanism.

Message queues: For high-scale systems, you might use RabbitMQ, Kafka, or Redis Pub/Sub. The pattern remains identical; publishers emit events, subscribers register interest, but the infrastructure handles reliability, ordering, retry logic, and persistence.

The point is: learn the pattern once, apply it at any scale. A local event bus in your monolith teaches you the same concepts you'll use with Kafka in a microservices architecture.

Real-World Example: Order Processing

Let's look at a more realistic scenario. An e-commerce system processes orders:

const eventBus = new EventBus();

// Payment service
eventBus.on('order:placed', async (order) => {
  console.log(`Processing payment for order ${order.id}`);
  const paymentSuccessful = await processPayment(order);
  
  if (paymentSuccessful) {
    eventBus.emit('order:paid', order);
  } else {
    eventBus.emit('order:payment-failed', order);
  }
});

// Inventory service
eventBus.on('order:paid', (order) => {
  console.log(`Reducing inventory for order ${order.id}`);
  order.items.forEach(item => {
    reduceStock(item.productId, item.quantity);
  });
  eventBus.emit('order:inventory-updated', order);
});

// Shipping service
eventBus.on('order:inventory-updated', (order) => {
  console.log(`Creating shipping label for order ${order.id}`);
  createShippingLabel(order);
  eventBus.emit('order:ready-to-ship', order);
});

// Notification service
eventBus.on('order:ready-to-ship', (order) => {
  console.log(`Notifying customer about order ${order.id}`);
  sendEmail(order.customer.email, 'Your order is being prepared!');
});

eventBus.on('order:payment-failed', (order) => {
  console.log(`Notifying customer about payment failure for order ${order.id}`);
  sendEmail(order.customer.email, 'Payment failed, please try again');
});

// The order controller just kicks things off
function placeOrder(orderData) {
  const order = {
    id: generateId(),
    ...orderData,
    status: 'pending',
    createdAt: new Date()
  };
  
  saveOrder(order);
  eventBus.emit('order:placed', order);
  
  return { orderId: order.id, status: 'processing' };
}

Notice the chain of events: order placed → payment processed → inventory updated → shipping prepared → customer notified. But each step only knows about its immediate inputs and outputs. The payment service doesn't know about inventory. Inventory doesn't know about shipping. They're all independent, connected only through events.

If you need to add a new step, say, checking for fraud, you just add another subscriber:

eventBus.on('order:placed', async (order) => {
  const isSuspicious = await checkForFraud(order);
  if (isSuspicious) {
    eventBus.emit('order:flagged-for-review', order);
    // Payment service might listen for this and pause processing
  }
});

No changes to existing services. Just wire in the new behaviour.

Access a Global pool of Talented and Experienced Developers

Hire skilled professionals to build innovative products, implement agile practices, and use open-source solutions

Start Hiring

The Trade-Offs

  • Harder to trace: The execution flow is implicit, making debugging trickier than with direct function calls. Connections are registered at runtime, requiring good logging to understand the flow. In production, use structured logging with correlation IDs and distributed tracing tools.
  • Error isolation: In basic implementations, if one subscriber throws an error, it can prevent others from running. Production event buses must wrap each callback in try/catch blocks to handle failures gracefully, logging errors and continuing notification.
  • Eventual consistency: Events are not always instantaneous, especially in distributed systems. There's a time window where one service has acted, but others haven't processed the event yet.
  • No return values: Emitting an event is a "shout into the void." You cannot get a synchronous response, necessitating different workflow structures (e.g., using follow-up events).
  • Potential for chaos: Over-reliance can lead to a tangled mess of events, triggering other events. They should be used primarily for side effects and notifications, not for core business logic requiring synchronous validation.

When to Use Event Buses

Use event buses when:

  • You have side effects: The core action succeeds or fails independently of secondary actions (e.g., user registration succeeds even if the email send fails).
  • You have multiple reactions to one action: Several independent things need to happen when an event occurs.
  • You want flexibility: You expect to add or change reactions to events without modifying the code that triggers them.
  • You're building loosely coupled systems: Microservices, plugins, modular architectures, or anywhere components need to communicate without tight coupling.

Don't use event buses when:

  • You need synchronous responses: If the caller needs an immediate answer or needs to know if something succeeded before proceeding, use direct calls or async/await.
  • The flow is simple and linear: If A always calls B and only B, and that's never going to change, direct function calls are sufficient.
  • Debugging complexity isn't worth it: Small projects or prototypes where the traceability of direct calls is more valuable than decoupling.

Final Takeaway

Event buses solve complex coupling issues by allowing publishers to announce events and subscribers to react, connecting them without direct links. This simple, flexible concept is scalable from a small monolith to millions of events across services.

Start simple: use an event bus to split an overloaded function into subscribers. This makes adding features easier without modifying existing code, improving system maintainability. As your system grows, the same pattern scales with you—just swap the transport mechanism while keeping the mental model intact.

Frequently Asked Questions

Q: What's the difference between an event bus and a message queue?

Conceptually, they're similar; both handle distributing messages to interested parties. But message queues (like RabbitMQ or SQS) are infrastructure components designed for distributed systems, with features like persistence, guaranteed delivery, and ordering. An event bus can be a simple in-memory object (like we built) or it can be built on top of a message queue. Think of event buses as the pattern, message queues as one possible implementation.

Q: Can multiple subscribers handle the same event, and do they run in parallel?

Yes, multiple subscribers can (and usually do) handle the same event. In our in-memory implementation, they run sequentially in the order they were registered. In production systems, you might want them to run in parallel using Promise.all(), or you might want certain ordering guarantees. The event bus can be extended to support either pattern depending on your needs.

Q: What happens if an event subscriber throws an error?

In our basic implementation, an error in one subscriber would prevent other subscribers from running. Production event buses usually wrap each subscriber in a try-catch to isolate errors, log them, and continue notifying other subscribers. You can also emit an error event that other services can subscribe to for monitoring.

Q: How is this different from using callbacks or promises?

Callbacks and promises are for one-to-one communication: this function calls that function and gets a response. Event buses are for one-to-many broadcast: this event happened, and anyone who cares can react. With callbacks, the caller needs to know who to call. With events, the emitter doesn't know or care who's listening.

Q: Should I use event buses in a frontend app?

Absolutely. Many state management libraries (like Redux, MobX, or Vuex) are essentially event buses. UI components subscribe to state changes, actions emit events that modify state, and components react. Even without a framework, event buses work great for decoupling UI components, especially in complex applications.

Q: How do I handle events that need to happen in a specific order?

There are a few approaches: emit events sequentially (each handler emits the next event when done, like our order processing example), use event metadata to specify dependencies, or use a workflow engine for complex orchestration. For simple cases, sequential event chains work fine. For complex cases, you might need something more sophisticated.

Q: What about performance? Isn't this slower than direct function calls?

In-memory event buses are extremely fast; they're just iterating through an array and calling functions. The overhead is negligible compared to the actual work being done (database calls, API requests, etc.). In distributed systems, the network latency of HTTP requests or message queues is the bottleneck, not the event bus pattern itself. Don't optimise prematurely; the decoupling benefits usually far outweigh any microscopic performance cost.

Subscribe to our newsletter

The latest in talent hiring. In Your Inbox.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Hiring Insights. Delivered.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Request a call back

Lets connect you to qualified tech talents that deliver on your business objectives.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.