The Microfrontend Communication Problem: Why We Built an Event Bus

SS Saurav Sitaula

Microfrontends are great until they need to talk to each other. How do you pass data between two React apps that don't share a state tree? Redux? Context? No. We went back to basics and built a vanilla JavaScript Pub/Sub Event Bus. Here's why it works.

The Problem: Silos Without Communication

By mid-2024, our microfrontend architecture for our e-commerce site was a massive success. The product-app and the cart-app were deploying independently. Bundle sizes were tiny. Teams were autonomous.

We had successfully decoupled our codebases. But then we hit a wall.

We needed a feature: When a user clicks “Add to Cart” on a product card, the shopping cart drawer needs to slide open and update its item count.

In a monolithic React app, this is trivial. You lift state up. You put a piece of state in a parent component (App.js), or drop it into Redux/Zustand/React Context, and both components read from it.

// The monolith way
function App() {
  const [cart, setCart] = useState([]);
  
  return (
    <>
      <ProductList onAddToCart={(item) => setCart([...cart, item])} />
      <CartDrawer cart={cart} />
    </>
  );
}

But in our microfrontend world, the product-app and the cart-app don’t share a React state tree. They are entirely separate Webpack builds. They don’t know each other exist. If you tightly couple them by passing props down from the host-app, you ruin the independence you just fought so hard to build. If the product team wants to change the payload of the item being added, they have to coordinate with the host team to update the props.

We needed them to communicate without coupling them.

The Solution: The Pub/Sub Pattern

We realized we didn’t need shared state. We needed shared events.

If the product-app simply announces to the browser, “Hey, an item was added to the cart!” and the cart-app happens to be listening for that announcement, they can stay completely decoupled.

This is the Publish-Subscribe (Pub/Sub) pattern. And we decided to implement it using vanilla JavaScript, attached directly to the global window object.

Building the Global Event Bus

Inside our host-app (the shell that loads first), we created a simple eventBus:

// Inside host-app/src/utils/eventBus.js
const createEventBus = function () {
  const events = {};
  
  return {
    subscribe: function (eventName, callback) {
      if (!events[eventName]) {
        events[eventName] = [];
      }
      events[eventName].push(callback);
      
      // Return an unsubscribe function
      return () => {
        events[eventName] = events[eventName].filter(cb => cb !== callback);
      };
    },
    
    publish: function (eventName, data) {
      if (events[eventName]) {
        // Call every function that subscribed to this event
        events[eventName].forEach((callback) => callback(data));
      }
    }
  };
};

// Expose it globally so all microfrontends see the EXACT same instance
window.eventBus = createEventBus();

That’s it. Thirty lines of code. No npm packages. No complex state management libraries. Just an object holding arrays of callback functions on the window.

Because the host-app attaches this to the window object when it first boots up, any microfrontend loaded on the page has access to the exact same event bus instance.

How the Features Use It

Let’s look at how the product-app broadcasts an event.

Publishing an Event

When a user clicks “Add to Cart”, the product-app doesn’t call a Redux dispatcher or update a Context. It just publishes an event with a payload:

// Inside product-app/src/components/ProductCard.jsx
function ProductCard({ product }) {
  const handleAdd = () => {
    // Publish to the global bus
    if (window.eventBus) {
      window.eventBus.publish("cart:add-item", {
        productId: product.id,
        name: product.name,
        price: product.price,
        timestamp: Date.now()
      });
    }
  };

  return (
    <div className="card">
      <h3>{product.name}</h3>
      <button onClick={handleAdd}>Add to Cart</button>
    </div>
  );
}

The Product app doesn’t care who is listening. It just shouts into the void.

Subscribing to an Event

Meanwhile, the Cart app is quietly listening. When it boots up, it subscribes to the "cart:add-item" event:

// Inside cart-app/src/components/CartDrawer.jsx
import { useEffect, useState } from 'react';

function CartDrawer() {
  const [items, setItems] = useState([]);
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (!window.eventBus) return;

    // The handler function
    const handleAddToCart = (data) => {
      console.log(`Received item: ${data.name}`);
      
      // Update local state
      setItems(prevItems => [...prevItems, data]);
      
      // Open the drawer automatically
      setIsOpen(true);
    };

    // Subscribe to the global bus
    const unsubscribe = window.eventBus.subscribe("cart:add-item", handleAddToCart);

    // Clean up the listener when the component unmounts
    return () => unsubscribe();
  }, []);

  return (
    <div className={`drawer ${isOpen ? 'open' : 'closed'}`}>
      <h2>Your Cart ({items.length})</h2>
      {/* ... render cart items ... */}
    </div>
  );
}

The Cart app intercepts the data and updates its own internal React state. It has no idea that the Product app triggered the event. It just knows the event happened.

Standard DOM Events: The Alternative

Sometimes, you don’t even need a custom bus. The browser already has one: the DOM.

For broader application-level hooks, you can just dispatch standard CustomEvent instances on the document or window:

// Dispatching from product-app
document.dispatchEvent(new CustomEvent('cart:add-item', { 
  detail: { productId: 123, name: "Sneakers" } 
}));

// Listening in cart-app
document.addEventListener('cart:add-item', (e) => {
  const item = e.detail;
  console.log(item.name);
});

This is native to the browser and works identically to our custom Pub/Sub bus. It’s slightly more verbose to write, but it requires zero setup code. Many teams prefer this because it uses web standards.

What I Wish I’d Known Earlier

  1. Namespace your events. If you just name an event "add", you will have collisions. Use strict naming conventions like "domain:entity:action", such as "cart:item:added".
  2. Always clean up your subscriptions. The useEffect cleanup return is not optional. If you don’t unsubscribe when a microfrontend unmounts, you will create massive memory leaks and duplicate event fires if the component remounts.
  3. Don’t use the bus for everything. It’s tempting to use Pub/Sub for all state. Don’t. If data is strictly internal to the cart-app, use React State or Context. Only put events on the bus if they cross a microfrontend boundary.
  4. Events are hard to debug. When state lives in Redux, you have Redux DevTools. When state flies across a window object via strings, it’s invisible. We eventually added a console.log interceptor to our eventBus.publish function just so we could see the events flowing in the dev console.

The True Power of Agnostic Communication

Building this event bus taught me a valuable lesson about architecture: Technical decoupling requires communication decoupling.

If two apps use Webpack Module Federation to load independently, but one relies on a Redux store exported by the other, they aren’t decoupled. You’ve just created a distributed monolith. You can’t deploy one without testing the other.

By forcing communication through an agnostic, string-based Event Bus, we ensured true isolation. The Cart app can be rewritten in Vue or Svelte tomorrow. As long as it listens to window.eventBus.subscribe("cart:add-item"), the e-commerce system won’t break.

That’s the promise of microfrontends, finally realized.


P.S. — I spent three days researching complex cross-application state-syncing libraries for microfrontends before realizing a 30-line vanilla JS object solved the problem perfectly. Sometimes, when dealing with cutting-edge Webpack 5 architecture, the best solution is a pattern invented in the 1980s.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism