Scaling your micro-frontends off the main thread

Tobias Uhlig
ITNEXT
Published in
11 min readJan 17, 2022

--

While I have seen and read about quite a few approaches to create micro-frontends and enhance apps with lazy loading strategies to fetch new views exactly when you need them, most of them felt difficult to implement and not really thought through till the end. This article will show you an approach which is actually easy to implement and extremely powerful.

Regarding the micro-frontend decisions framework, we will use:

  • Horizontal split
  • Client-side composition
  • Client-side routing

To make this approach special, we will add the following design goals on top:

  1. Our MFEs and apps will live within a web worker, which leads to a blazing fast rendering performance
  2. The dev mode version of our code has to run as it is inside the browser (zero builds or transpilations)
  3. We want to use different versions of our MFEs in different parts of our app
  4. We want to lazy load our MFEs when needed

Content

  1. Setting up a monorepo-like structure
  2. Creating our main application
  3. Creating our first micro-frontend
  4. Including MFE_1 into our main app
  5. Creating our second micro-frontend
  6. Including MFE_2 into our main app
  7. Client-side routing
  8. Production build for our MFE composition
  9. Does our code really live within the app worker?
  10. Enhancement ideas
  11. Final thoughts

1. Setting up a monorepo-like structure

I created a new repository for this article:

I am using the word “monorepo-like”, since for a real monorepo, you would probably use a build tool (e.g. Bazel, Lerna, yarn workspaces).

You would also setup multiple branches, at least “main” and “dev”, plus feature branches as needed.

To keep this demo as simple as possible, we are just using the main branch.

The root folder does not contain a package.json file, so each top-level folder will contain an isolated npm package.

In case your company has multiple dev teams, which work on different areas of a complex app or in case you are about to migrate to a monorepo, this strategy can make sense.

Of course it would be nice, if each team (package) would use the same dependencies in the long run, but it can make migrations slower and there might be valid reasons even for different versions of the same dependencies.

To create this basic setup, I am using npx neo-app 4 times within the root folder of the repo.

At this point, all 4 top level folders contain the same dummy app and can get build and deployed on their own. Let us take a quick look into one of them:

The .gitignore already excludes the dist as well as the node_modules folders for us.

2. Creating our main application

Since we already have a dummy app in place, let us take a quick look into it:

The index.html will only include the MicroLoader . This one will create the workers-setup for us:

The application worker, which is our main actor, an orchestrator and our app related build entry-point, will fetch our application shell for us:

Our application shell is as lightweight as possible and not related to Components. However, we can define a mainView here as well as a parentId , in case we want to render this app into a specific DOM node.

You can think of the app.mjs file like an index file (starting point) for the app worker.

So, we can start right away with adjusting the content of our main app.

We will start with a very simple structure:

Assuming “Home” will contain a complex view, we will create an own class for it:

Let us take a look into our Viewport (main view) next:

The special part here is that we dynamically import our HomeComponent once the first tab gets activated. This is not important yet, but it will be once we set up the client-side routing.

Inside a real app, we would use the theming engine to style our content, but the goal is to keep it simple and focus on the core topic: micro-frontends.

3. Creating our first micro-frontend

While we could create this component directly inside the apps folder inside the mfe_1 top-level repo folder, let us use the src folder in there instead to keep it clean:

Since our team which works inside this scope wants to see and test it independently from our main app, let us include it into the apps folder of this scope first (this won’t affect our main apps or the builds for it).

In case you look close: we are using neo.mjs related imports from our main directory to avoid fetching framework-related files multiple times.

We can open it inside our browser right away, without any builds or transpilations and the related team can work and test the micro-frontend from here on already.

4. Including MFE_1 into our main app

I am a bit scared to show you this approach, but if you like to, you can directly include the always latest version very easily:

We are adding MFE_1 as a dynamic import as well. In case you open the network tab inside the developer tools and switch to our new tab inside our main app, you will notice that our new component will get lazy loaded at this point.

You will also notice that we are displaying a different text. Following the unidirectional dataflow paradigm, it is fine for parent components to directly change class configs (similar to props in React).

Warning: Unless your teams are extremely careful in using different feature branches and pushing code to the main branch only in case they publish a new package version, you risk that code changes inside our micro-frontend code base break the main app. Even “only” breaking the dev branch can slow down other teams significantly.

The clean way would be to adjust our package.json file and publish MFE_1 to npm at this point:

Now our top-level repo folders can add this package into their package.json files as needed and install the dependency. This way you are also in control to use different versions.

5. Creating our second micro-frontend

This time, we are creating a small component tree and we will fire an event.

We will adjust our standalone testing app as well:

And we get the following result:

6. Including MFE_2 into our main app

Like with MFE_1, we will lazy load our new Panel when it is needed. We will also add a listener for our custom event, which we will map into our new view controller.

Here is the code of the new view controller:

Result:

Following the unidirectional data flow paradigm again: Modifying the state or configs of parent components is not a good idea. Instead, we will fire events which parent components can subscribe to.

Hint: Using a PubSub implementation is not a good idea, since message busses do not scale. They can easily get flooded with a lot of input. The observable driven implementation here feels a lot more elegant.

7. Client-side routing

This part is fairly easy. We need to add routes to our tabButtonConfig objects:

The next step is to add the onHashChange() logic into our view controller:

We do not only generate routes when switching the main tabs, but we can also now load our app using routes, which will only load the view we need.

If you look close, you will notice that in case we start with loading the MFE_2 route and then switch to the MFE_1 tab, nothing will get lazy loaded. This happens, because MFE_2 includes MFE_1 already.

When inspecting the DOM, you will notice that only the active tab is mounted. There is more to it: We can optionally keep the JS instances of our tabs in memory (which is the default behaviour) and only add and remove the existing virtual dom as needed.

Meaning: The constructor()of each tab will only trigger once and the render() method of each tab will only trigger once as well.

The client-side routing it not limited to tabs only. We could use card layouts or other structures and handle the logic inside our view controllers.

8. Production build for our MFE composition

So far, the entire code was running without any builds or transpilations directly inside the browser. This speeds up the development quite a bit.

However, we do want to bundle our files which are needed for each state accordingly. Webpack is a great tool for creating split chunks for our dynamic imports inside the app worker scope.

All we need to do is running the build-all script inside the package.json of our main top-level folder.

Hint: In case we only change app related code, we can use the build-my-apps script instead, which is way faster.

We could also generate multiple apps within our main/apps folder and we would get cross apps split chunks out of the box. Meaning: Dropping multiple apps into the same page will have close to zero overhead.

Let us take a look into the dist/production version now:

You will see that we will load way less files. For the Webpack based version, we need to switch to the Other tab inside the dev tools network tab. This is realated to Harmony, which can get removed once Mozilla (Firefox) catches up to support JS modules within the worker scope.

Right now, the dev mode runs inside Chromium and Safari, while the dist/production version runs inside all major browsers. No worries, there is a dist/development version in place as well (supporting all browsers).

9. Does our code really live within the app worker?

You will hardly notice the difference while creating your apps, except from window and window.document being undefined.

No cheating :)

Your apps, components and micro-frontends truly live within the app worker scope.

10. Enhancement ideas

There is still one catch to resolve. Browsers do not support bare module specifiers and we can not use variables inside our import statements or dynamic imports.

Variables would help us a lot, e.g. specifying a base path for our URLs.

One way to resolve this would be to put neo into a CDN and fetch all framework related files from there.

What I would like to do is enabling us to put the framework into a top-level folder of the monorepo structure. This enables us to fetch all files from there and create PRs for feature requests or bugs in a convenient way.

I will most likely need to adjust the build-scripts to handle this.

To support importing MFEs in different versions (added as dependencies inside top-level folders), we need a new build-script as well to adjust the framework related import paths as needed (unless we go for the CDN).

Please give me a heads up, in case you would love to see this happen!

I can create a part2 of this article, in case you have ideas what you would like to see inside the MFE_3 implementation. I was thinking about a more complex view, showing more events and adjusting the other 2 MFEs at run-time. MFE_3 should most definitely contain a view model (similar to a store / state provider like e.g.MobX) and logic how to work with it. I skipped this part for now, to keep the size of this article reasonable.

11. Final thoughts

While I strongly recommend sticking to a single workspace (npx neo-app) if possible, the monorepo-approach can be great, especially in case multiple teams want to work on different scopes and require different dependencies (or different versions of the same dependencies).

Creating this PoC demo and writing this article literally only took a single day. Trust me, writing the article took most of that time.

You might have noticed, that we are not using module federation at all. We do not need to specify any remotes and can freely use and lazy load micro-frontends as we like to.

While we can not (and don’t want to!) create a dist/prod version of each MFE on its own which we consume, we can just create our app related build versions as needed.

The build times will be a bit slower, however, keep in mind that we only need builds in case we want to deploy to dist/prod. For the development, we do not need any builds or transpilations at all.

I have not found the time yet to fully investigate module federation though. In case there are use cases which would work well within the neo scope → feature request or PRs please.

You are welcome to create new feature requests directly inside the neo repo:

The current plan is to focus on the new learning section next (massive epic):

You can find all mentioned files inside this article here:

Feel free to hop into the slack channel for ideas, feedback and questions:

Best regards & happy coding,
Tobias

P.S.: i will create an online demo soon!

Preview image:

--

--