Problems of Event-Based Communication in Micro Frontends

Micro Frontends are quickly becoming the preferred approach to building enterprise-grade user interface applications on the web. They are the adaptation of the microservices architecture but on the UI side, and so this comes with the advantages that you would expect: decoupling, partial deployments, increased development velocity, error isolation, ... As a few examples.

The Very Good Parts Anchor link

Where I work, we quickly realized the advantages of using Micro Frontends over a monolithic architecture and we put them to good use: we built multiple very large enterprise-grade UI applications on the web. This improved our development velocity on the UI greatly, and it suited us well because of how many teams we have. Additionally, because Micro Frontends are supposed to be decoupled, our applications could now be split based on domain or feature, which in turn facilitated the creation of full-stack teams of developers: one microservice and one Micro Frontend for each team, like below.

Aspects of an application being split between teams using Micro Frontends and microservices

Splitting systems with Micro Frontends and microservices. Source

Our teams are now independent of each other from the perspective of development and feature introduction. The only contract each Micro Frontend team adheres to is to not modify interfaces already exposed and in use by other components of the app. Besides that, every team has great flexibility and this allowed for fast feature development and deployment.

There are also other advantages that are self-evident: integration testing is now done by partially deploying one single Micro Frontend in a test environment; a Micro Frontend can be quickly integrated and used in other existing applications; failures are not propagated to the entire application.

The Problem: Event-Based Communication Between Asynchronous Components Anchor Link

Certain problems arise when MFEs need to communicate with each other, and we discovered this in our own applications.

First, a bit of background regarding how an MFE-based application might be organized from an architectural perspective: Micro Frontends are not pages. Different Micro Frontends can compose a page, each representing a specific component. For instance, in a networking application, one MFE might display information regarding inbound and outbound traffic of a network, while another might display the constituent objects of the network (switches, VMs, hypervisors, etc.).

Two Micro Frontends coexisting in the same page

Two MFEs on the same page

One characteristic of Micro Frontends is that each is its own bundle file, meaning the user's browser will finish downloading them from the server at different points in time. This asynchrony has certain advantages:

  • asynchronous loading of Micro Frontends enables the application to be reactive as soon as possible to user interactions;
  • network and hardware load is distributed over a certain timeline, which increases perceived performance;
  • asynchronous loading avoids head of line blocking problem if a Micro Frontend requires an abnormally large amount of time to load.

So the order in which the bundle files are downloaded is non-deterministic. Thus the same load order on each refresh is not guaranteed. The load times may vary from MFE to MFE depending on size, network characteristics, whether a server is on fire somewhere, load and other factors.

Why is this relevant? In a complex UI application (e.g., an enterprise one) it is wise to assume that operations such as scaffolding, data binding, data sourcing and information sharing between components will take place along the lifecycle of the application. The problem in the context of MFEs arises when these operations take place at the time the former are initialized and are done through communication via events. Even though MFEs are decoupled, some need to communicate with each other. What happens when a subscriber MFE was not yet initialized at the time a publisher MFE has fired an event? Exactly nothing.

The flow would look like this:

Flow of broken event-based communication

Subscriber MFE missing events from publisher MFE

The flow above starts when an application composed of multiple MFEs is accessed in the browser. There are two vital components: an MFE that fires an event (i.e., a publisher), an MFE that registers a listener for that event (i.e., a subcriber). Both components are initialized and loaded at different points in time, more specifically: the publisher is loaded faster than the subscriber. This means that the event the publisher has fired was not intercepted by any event listener. So, what happens is... nothing. The event is freed by the garbage collector, so the object cannot be processed by any other component in the application.

This is a major problem, and it cannot be solved by existing browser APIs. Imagine a page composed of multiple Micro Frontends, there is a publisher-subscriber relationship between two of them, and the subscriber performs a critical feature whenever an event is fired from the publisher. If the events are not intercepted:

  • features will be disrupted;
  • user experience will suffer;
  • users will find the application poor performing and unresponsive to their inputs.

Bad Solutions Anchor Link

Let's think about a way to solve this problem in an inappropriate manner. There are a couple possibilities:

  1. wait for subcribers to be loaded before firing any event;
  2. ditch events and use a global state to share data;
  3. remove any kind of communication between MFEs.

Let's see why all of these three are impractical.

First things first: waiting for all subscribers to be present before publishing any event. This is obviously detrimental to perceived performance, and it destroys the advantage of asynchronous MFE loading. Not only that, Micro Frontends can be both subcribers and publishers. The dependencies that MFEs will have between themselves will be hard to manage, which defeats the purpose of compartmentalization.

Second: ditching events and using a global state for data sharing and interaction between two MFEs. This creates coupling between MFEs and subsequently, teams. "Why is your MFE modifying the state like this?!" is a question you and your colleagues will ask very frequently if you take this approach to communication. Micro Frontends should be as decoupled as possible, but full decoupling is not always feasible, which leads us to the next point...

Third: remove any kind of communication between MFEs... For a non-mediocre application, eliminating communication is not practical. This is because features (i.e., MFEs) depend on each other. There are relationships between the components of a system such that interaction with one of them will trigger reaction in another. At work, in each of our MFE-based applications there are these kinds of relationships: click on an entry in a table -> display detailed information regarding that entry in a another part of the page. A pan and a stove are very different objects, but you can't cook your pancakes without one or the other.

The Solution: Event Queues Anchor Link

The solutions to this problem consists of using event queues.

Since Micro Frontends are the adaptation of microservices to the UI, we can borrow patterns from one to the other. Event queueing already existed as a concept and in practice on the backend, not so much in the context of UI. Even so, this technique can be used to solve our problem in the most elegant way.

We need to modify our previous flow where the subscriber didn't process the event by adding a few more steps. We store the event in a queue, so events are propagated equitably (FIFO). This keeps a reference to the event and thus it will no longer be freed by the garbage collector. We match the listener to the event in the queue. Finally, it is processed by the listener and dequeued.

Flow of correct event-based communication

Subscriber MFE correctly intercepting events from publisher MFE

From a high-level, our shopping list has a queue and an arbitrary amount of events in that queue. And we also need a place to hold listeners meant to process the events in the queue. Additionally, we need a method to match events to listeners, which can be done using some identifier or other. So our architecture would look like so:

Event queue and matching between events and listeners

Messaging system architecture with event queue and listeners

Notice that an event can be processed by multiple listeners. After being processed by every appropriate listener, the event can be dequeued.

But wait... How should an event look like? I.e., what's its interface?

Pretty simple stuff: an identifier and an optional payload as a JavaScript object.

const event = {
    identifier: "unique:id",
    payload: {
        awesomeSciFiSeries: "Dune"
    }
}

The Implementation Anchor Link

Implementing a messaging system that does this can be done using plain JavaScript. Making it such that every MFE-based application can use it regardless of the underlying UI framework.

First things first: we need a way to instantiate our messaging system. A class will do just fine.

Second: we need a queue.

Third: we need a structure that will hold our listeners. Without listeners, no event will be processed. You'll notice that this pattern that we're using is very similar to how browser events API works. This is deliberate, we don't need the overhead of learning to use a complex tool.

// We'll call our messaging system "Broker"
class Broker {
    constructor() {
        // Queue that should store our events such that we have a reference to them 
        this.queue = []; 

        // Object that will hold our listeners
        this.listeners = {};
    }
}

All well and good, but instantiating empty objects is basically useless. We need some more operations to make this system actually useful: a method for firing events, a method for registering listeners, and a way for the two to be matched such that events are processed by the appropriate listeners.

Registering Listeners Anchor Link

Before writing the code for firing events, we need to decide how the messaging system will match listeners to events. We can take some inspiration from the Custom Events API already present in browser environments. When instantiating a custom event you can specify its type, which is a string that can be used to identify the event when registering a listener. I.e...

const event1 = new CustomEvent("event1");

obj.addEventListener("event1", (event) => ...);

Adapting this approach to our own case, we can make it such that each fired event will have a string identifier specified by the developer. Any listener wishing to intercept the event need only know the identifier of the event:

class Broker {
    // ...

    registerListener(eventId, callback) {
        const listenerId = getRandomId();
        if (typeof this.listeners[eventId] !== "object") {
            this.listeners[eventId] = {
                [listenerId]: callback
            };
        } else {
            this.listeners[eventId][listenerId] = callback;
        }

        return listenerId; // return an identifier that can be used to unregister the listener afterwards
    }

// ...

this.listeners is a map that holds listeners for each possible event in the application. When an event is fired Broker can check whether listeners exist for said event and run their callbacks. We're also returning a listener identifier to be used when wanting to remove the listener (note: always unsubscribe, don't hog memory in your applications).

A method for removing a listener could look as such:

class Broker {
    // ...

    removeListener(eventId, listenerId) {
        delete this.listeners[eventId][listenerId];
    }

// ...

Firing Events Anchor Link

Now let's take a look at how our messaging system will fire events. Yes, we need a method.

More specifically, we need a method that enqueues an event. The method receives as an argument an event object adhering to the interface that we defined above.

class Broker {
    // ...

    fireEvent(event) {
        this.queue.push(event);
    }

// ...

You might notice that something is missing though... Events and listeners exist in our Broker class, but there's no mechanism matching the two! We need to write some code that runs a listener's callback when an event is intercepted.

Processing Events Anchor Link

Let's think when our messaging system needs to process events...

When an event is at the head of the queue.

Yes! And there's one more situation when events should be processed... The one we've been trying to solve from the beginning: we need to make sure we process appropriate events when a listener is registered. This is because events can be enqueued before a listener for them is registered, just as we discussed in the solution section.

We need to fire a notification whenever: an event is enqueued; when a listener is registered. We can start processing the events in the queue. When no events are in the queue, we stop processing, until we get another notification. A function that would take care of processing events could look like so:

class Broker {
    constructor() {
        // ...

        // flag to specify if any work is done at the moment 
        processing = false;

        // ...
    }
    
    process() {
        // if the queue is empty, processing is no longer done
        if (this.queue.length === 0) {
            this.processing = false;
            return;
        }
        
        this.processing = true;

        // take the first event in the queue 
        const event = this.queue.shift();

        // call every callback for the event
        if (this.listeners[event.identifier]) {
            for (let callback of Object.values(this.listeners[event.identifier])) {
                callback(event);
            }
        }

        // call function recursively
        this.processing = this.process();
    }

// ...

It controls a flag that tells whether the messaging system is processing events at the moment or not. This is useful because it prevents this.process() from being called without needing to. So, when firing an event or registering a listener, we can look at the this.processing flag before starting actual processing:

class Broker {
    // ...

    registerListener(eventId, callback) {
        // ...

        if (!this.processing) {
            this.process();
        }

        // ...
    }

    fireEvent(event) {
        // ...

        if (!this.processing) {
            this.process();
        }

        // ...
    }

// ...

Aaaand we're done! This should ensure events are delivered regardless of when they were fired.

Wait... What happens when an event has no listeners?

Astute as you are, you may have noticed that inside of this.process() we are not handling the case where an event has no listeners. This is more up to the specific application that uses the messaging system, but reliability can't be ignored. To solve this problem, we'll have to make it such that an event does not block the queue. In other words, we need to think of a way to avoid a head-of-line blocking problem.

Kick the events out if they don't pay rent!

We'll do something of the sort. An event will stay a maximum of 3000 milliseconds in the queue. After that time elapses, the event will be dequeued and processing will continue. Let's modify our this.process() function to do just that.

class Broker {
    constructor() {
        // ...

        // specify whether the head event is set to be dequeued or not 
        this.eventDequeuingInProcess = false;         

        // ...
    }

    // ...

    async process() {
        // ...

        const event = this.queue[0];

        if (this.listeners[event.identifier]) {
            // reset dequeuing flag such that an event is not
            // dequeued immediately if there are no listeners
            this.eventDequeuingInProcess = false;

            // deque event since we know that there are listeners for it
            this.queue.shift();

            // ...
        } else {
            // if there are no listeners, stop processing for 3 seconds,
            // then try again
            if (!this.eventDequeuingInProcess) {
                this.eventDequeuingInProcess = true;
                await this.sleep(3000);
            } else {
                // if there are no listeners, and we already waited
                // 3000 milliseconds, dequeue it
                this.eventDequeuingInProcess = false;
                this.queue.shift();
            }
        }

        // ...
    }

    // Yes, it is weird. But there's no other function that
    // allows a program to stop execution temporarily in 
    // JS browser environments
    sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

// ...

Let's walk through what's happening here:

  1. Start processing when either an event is fired or when a listener is registered;
  2. Take event at head of queue and start processing;
  3. If event has listeners run each callback, dequeue the event immediately;
  4. If event has no listeners, stop and try again or dequeue it depending on if we already stopped and waited or not.

This is possible because this.process() is called recursively.

Graphically, the flow looks like so (note: it's easier to understand a problem if you visualize it. I'm using draw.io for this, because its open-source).

Flowchart representing the code above

Flow of solution for HOLB problem using sleep

Conclusions Anchor link

Now you know how to solve the problem of event-based communication between events. You can also adapt the solution to your specific needs, since it is written in plain JS. Also, remember to use the same instance for all MFEs that communicate with one another.