How to Easily Create an Event Bus for a Micro Frontend
Sep 5, 2022 ☕ 13 min readCommunication and state management in a micro frontend are challenging problems - just like they are for backend systems using microservices.
Any shared state between applications in distributed systems creates a slew of headaches: scalability, bottlenecks, versioning, deployments, rollback, change management, and team coordination to name a few.
Using a publish & subscribe pattern with an event bus we can enable inter-application communication without direct coupling and thus avoid many of these problems altogether.
Publish & Subscribe (PubSub)
The Publish & Subscribe (aka PubSub) pattern is an event-based mechanism to send and receive messages in a system. It allows applications to publish messages to a middleman without knowing who (if any) applications are listening.
Similarly, applications can subscribe to these messages without knowing who is publishing them. This middleman can be an event bus or a message queue responsible for receiving, managing, and sending out messages.
This creates independence between the applications and the platform. Anyone can freely subscribe and publish messages as they wish without worrying about coordination or communication with other systems directly.
Typically we create separate channels within the PubSub for separating different types of messages from each other. This allows publishers and subscribers to receive only messages specific to their use cases.
These types of event-driven patterns are extremely powerful for event-based interactions, transactions, or in a distributed system where coordination between systems would be challenging - as is the case for micro frontends.
Events are not new to frontend. Browsers dispatch events to indicate changes to the DOM or interactions from the user. DOM elements can act as event buses to dispatch and listen to new events. In a way, we're already using event buses in our frontend applications.
Considerations
Here are some key questions to consider when designing communication for your micro frontend architecture. The answers to these will help determine which implementation mechanism, exposure interface and structure best suit your specific needs.
- Does the order of messages matter? Does it need to be guaranteed?
- Do any messages need to be stored or persisted?
- Do all applications initialize at the same time? What happens if an application initializes after some messages have already been sent?
- What kind of developer experience do you want? Opinionated and structured or do you want to give developers more freedom?
- How will you test and deploy your applications? How can you debug messages being sent?
- Do you have a separate strategy for sharing state between micro apps? Do we want to use an event bus for that as well?
Implementation Mechansims
There are several options available to us for creating an event bus implementation in the frontend. These are the ones we will cover:
CustomEvent
: Custom events are events we can create in the browser and dispatch on a particular object or DOM node. The event itself can contain any data we pass and the object we use acts as the event bus.BroadcastChannel
: Broadcast Channel is a browser API for creating event buses. This API is unique in that it allows us to communicate across browsing contexts for the same origin. This means we can send messages across windows, tabs, frames, and iframes for the same origin.- Custom
PubSub
: We can create a PubSub mechanism ourselves which requires more legwork but gives us the most direct control over the system.
1. Custom Event
Custom Event is an API for creating new synthetic events. Unlike the Event API, with Custom Event we can add custom data to the event using its detail
property as shown below.
To publish and subscribe we use browser native APIs for adding event listeners and dispatching events. But we need to attach them to a DOM element on the page.
This could be any browser object or DOM node - a great option would be to use the comment DOM element since it is attached to the HTML but not visible to end users. This helps keep it hidden while making it accessible to our applications.
// Creating a comment elementconst elem = document.createComment("Event Bus");document.body.appendChild(elem);// Subscribe to messageselem.addEventListener("channel-1", (event) => {console.log(event.detail);});// Publish messagesconst event = new CustomEvent("channel-1", {detail: { message: "Hello World" },});elem.dispatchEvent(event);
The advantage of using Custom Events is the simplicity and cross-browser compatibility out of the box. We do not have to create anything new - just reuse existing APIs.
2. BroadcastChannel
Broadcast Channel is a relatively new API but supported by all modern browsers and is available using Web Workers. It allows us to create an event bus with a given channel name and others can subscribe and publish messages to it.
It is designed for cross-context communication so publishers and subscribers can communicate across windows, tabs, frames, and iframes of the same origin. This is extremely powerful for certain use cases - particularly when using iframes for a micro frontend implementation - but can be a bit overkill for just a simple messaging system in the same context.
Nonetheless, BroadcastChannels are incredibly simple to use:
// Connecting to a channel:const BC = new BroadcastChannel("My Channel");// Publishing a messageBC.postMessage({ data: { foo: "bar" } });BC.postMessage("Test");// Subscribing to messagesBC.onmessage = (event) => {console.log(event);};// DisconnectingBC.close();
The messages are automatically serialized by the API so you can safely send a broad variety of data. The API itself does not restrict the structure or data of messages, but it would be wise to add a filter when you subscribe to protect yourself from malformed messages.
3. Custom PubSub
A custom PubSub mechanism can be very powerful because it gives us full control over the system - particularly, what messages and topics are allowed. Unlike the previous two mechanisms, we can build in validation to ensure that only expected message formats are passed along.
To get started there are three methods we will need:
publish()
: Our applications will need a method to send messages to the event bus. These messages can be fully free form or we can enforce a strict structure depending on the use case.subscribe()
: Applications will need a way to listen for any new messages being created.unsubscribe()
: To help keep the overall system clean we need a mechanism for applications to unsubscribe
Topics
We will want to have dedicated channels in the event bus for specific topics to separate messages by their use case. These topics will be denoted by a unique string.
For simplicity, a topic will always be required to publish or subscribe to. You could extend this implementation by introducing publish and subscribe methods that do not require a topic so you could send or receive messages across topics.
Setup
We are going to use a class in TypeScript with three private objects to map subscribers to topics and listener functions.
export class PubSub {// Keep track of all `onMessage()` listeners with easy lookup by subscription id.private subscriberOnMsg: Record<ID, OnMessageFn> = {};// Keep track of the topic for each subscription id for easier cleanup.private subscriberTopics: Record<ID, Topic> = {};// Keep track of all topics and subscriber ids for each topic.private topics: Record<Topic, ID[]> = {};}
subscribe()
First, we will need to subscribe to new messages for a particular topic. The method will require a given topic
we want to listen to and a callback onMessage
function that will be triggered whenever new messages are published on the given topic.
/*** Subscribe to messages being published in the given topic.* @param topic Name of the channel/topic where messages are published.* @param onMessage Function called whenever new messages on the topic are published.* @returns ID of this subscription.*/public subscribe(topic: Topic, onMessage: OnMessageFn): ID {// Validate inputsif (typeof topic !== "string") throw new Error("Topic must be a string.");if (typeof onMessage !== "function")throw new Error("onMessage must be a function.");// Each subscription has a unique idconst subID = uuid();// Create or Update the topicif (!(topic in this.topics)) {// New topicthis.topics[topic] = [subID];} else {// Topic existsthis.topics[topic].push(subID);}// Store onMessage and topic separately for faster lookupthis.subscriberOnMsg[subID] = onMessage;this.subscriberTopics[subID] = topic;// Return the subscription idreturn subID;}
Example usage:
const PS = new PubSub();const subId = PS.subscribe("myTopic", (message) => {console.log({ message });});
publish()
Now that we can subscribe to messages we need a mechanism for publishing. Our publish()
function will also require a topic
argument to specify which channel to send the message to. Along with the message itself.
For simplicity, the message
argument can be any JavaScript object but we could restrict it by using a schema validation within the publish()
method if we wanted to.
/*** Publish messages on a topic for all subscribers to receive.* @param topic The topic where the message is sent.* @param message The message to send. Only object format is supported.*/public publish(topic: Topic, message: Record<string, unknown>) {if (typeof topic !== "string") throw new Error("Topic must be a string.");if (typeof message !== "object") {throw new Error("Message must be an object.");}// If topic exists post messagesif (topic in this.topics) {const subIDs = this.topics[topic];subIDs.forEach((id) => {if (id in this.subscriberOnMsg) {this.subscriberOnMsg[id](message);}});}}
Example usage:
const PS = new PubSub();PS.publish("myTopic", { message: "Hello World" });
Note: If your micro frontend architecture relies on iframes for rendering micro apps from different sources you can use the postMessage
API as a way to pass messages from the event bus to child applications.
unsubscribe()
Finally, we will need a mechanism to clean up our subscription data whenever the consuming components no longer need them. You should do this when the component unmounts or is no longer rendered on the page.
This method should receive a subscription id that can be used to clean up the private objects related to it.
/*** Unsusbscribe for a given subscription id.* @param id Subscription id*/public unsubscribe(id: ID): void {// Validate inputsif (typeof id !== "string" || !validateUUID(id)) {throw new Error("ID must be a valid UUID.");}// If the id exists in our subscriptions then clear it.if (id in this.subscriberOnMsg && id in this.subscriberTopics) {// Delete message listenerdelete this.subscriberOnMsg[id];// Remove id from the topics trackerconst topic = this.subscriberTopics[id];// Cleanup topicsif (topic && topic in this.topics) {const idx = this.topics[topic].findIndex((tID) => tID === id);if (idx > -1) {this.topics[topic].splice(idx, 1);}// If there are no more listeners clean up the topic as wellif (this.topics[topic].length === 0) {delete this.topics[topic];}}// Delete the topic for this iddelete this.subscriberTopics[id];}}
Exposing the PubSub
To make use of this PubSub across our micro apps we need a mechanism for the parent app to initialize it and share it with all child applications.
This could be done by either passing the instance of the class as a prop or argument to each micro app on initialization. Or we can expose the instance globally using the window
object or some other mechanism.
Sharing the instance globally is simple, convenient, and allows us to interact with the PubSub service using the browser's developer tools. But it can be risky so should be done carefully - making sure to lock down the implementation using methods such as Object.freeze()
to avoid any micro apps accidentally modifying the core logic.
Persisted Topics
Now we have a system that allows micro apps to send and receive messages independently through an event bus. If we want to use this pattern also for state management we could take this implementation a step further by persisting messages on specific topics.
There may be some topics we want to represent the state of our application at any given moment. For example, we may want to communicate to all micro frontends how many items are in a shopping cart.
This can be simple enough if all micro frontends are loaded first before any items are added. But what happens if we store the items in local storage or elsewhere such that users can come back to their shopping carts later? We need a mechanism for the PubSub to propagate this data whenever new micro apps are initialized.
To do this we can introduce "persisted topics" in which we store all messages for a given topic since the start of the application and as soon as new subscribers subscribe to those topics they receive all previous messages immediately.
To do this we would introduce a new private object storing the messages for persisted topics. In the constructor of our PubSub we can specify which topics to persist:
const PS = new PubSub({ persistedTopics: ["cart"] });
The subscribe()
method should be modified such that whenever new subscribers are stored we "flush" out any currently persisted messages by iterating through them and calling the subscriber's onMessage()
function immediately with each stored message.
The publish()
method should be updated to store the message in the new private object if the given topic is intended to be persisted.
Live Example
Code for a full example of the implementation can be found here: https://github.com/rautio/micro-frontend-demo/blob/main/main/src/services/pubsub.ts
It is used to power the product store in this demo application: https://micro-frontend-demo-main.vercel.app/ In which the PubSub manages items in the cart across a parent application and two child applications.
Final Thoughts
Communication in a micro frontend architecture can be challenging but an event bus pattern is all we need to create scalable, easy-to-use, and future-proof state management between our applications.
There are a few native APIs we can use to quickly get up and running - but a custom-built solution can prove more practical in larger applications where we want more features and control over the system.
What do you think?