Introduction
If you work on an application which involves multiple engineers, you’ll have noticed that it’s hard to approach working on a frontend simultaneously. This can lead to a lot of frustration and delay across teams, and the recent trend of splitting up monolithic frontends into smaller pieces has become popular.
This is known as a micro-frontend, and this article will look at how they work, why they’re effective, and how you can leverage this architecture in your own team.
Additionally, we’ll look at the benefits and costs so that you can establish whether you should be using a micro-frontend, rather than just chasing the latest craze.
By the end of this post, you should understand:
- The pros of micro-frontends
- The cons of micro-frontends
- The differences in integration approaches of micro-frontends
- How to implement a client-side integration of a micro-frontend
What is a micro-frontend?
A micro-frontend is an architecture where independent frontend codebases are combined into a larger application. You could create applications using different libraries such as React, or Vue, and also allow teams to work on applications independently, before bringing them together.
There are numerous advantages to this approach, namely that multiple teams can work on the frontend simultaneously without blocking one another, and you can easily version or customise components.
Integration of micro-frontends
If you can make a frontend application, congratulations! You can make a micro-frontend. There are a few approaches to implementing a micro-frontend though. The key difference lies in client-side integration, vs build-time integration.
Build-time integration
This integration strategy involves giving the container access to the dependency applications’ source code before it is all loaded in the browser.
The steps involved are:
- Work on application
- Deploy it as an NPM package (in a private registry if you wish)
- Install the package as a dependency in the container application
- Output a bundle which contains all the code for our application
This has a few disadvantages as the container must be re-deployed every time a dependency application is updated, and it can be very tempting to tightly couple dependent applications which defeats the purpose of a micro-frontend architecture.
Client-side integration
This integration strategy involves loading the dependency application source code after the container application is loaded in the browser. We simply expose an entry point and render the output.
- Work on application
- Deploy application to a static JS file such as https://remote.foo.com/widget.js
- User navigates to https://container.foo.co
- Container fetches widget.js and executes it
This is advantageous as the widget.js dependency application can be deployed independently at any time, and different versions of it can be deployed, deferring to the container as to which version should be used.
Which should I use?
It’s up to you! In this article we will discuss using a client-side integration. This a slightly trickier thing to achieve than simply combining the constituent applications at build time, but it allows us greater flexibility as you don’t need to re-deploy every time you change a dependency application, and you avoid tightly coupling applications.
Client-side micro-frontends
We’re using client-side integration via Webpack Module Federation. This is a flexible and performant solution which will give us a scalable architecture.
This works by:
- Designating our container as the host application, and our dependency applications as remote.
- In the remote applications, we decide which modules we want to make available to other projects.
- We expose those files via module federation.
- In the host we decide what we want to fetch from the remotes.
- We load the host’s entry point asynchronously.
- We load whatever we want from the remotes.
As you can see in the example repo, we have added a Module Federation Plugin into our webpack config. There is a sample remote application added there called ‘widget’. We can see this calls a localhost url. This works as follows:
Our remote application goes through its normal webpack bundling process, but additionally is processed by the Module Federation Plugin.
The remoteEntry.js file acts as a manifest and tells the container application how to load the files for the ‘widget’ application.
This means we can run the ‘widget’ application in isolation, but by adding the module federation plugin, we are able to include the ‘widget’ application in other applications.
On the container application side, we process our code with the Module Federation Plugin, and we generate the contents of ‘index.js’. This way, Webpack knows that it needs to fetch something from the remote applications.
As a flow, this looks like:
- main.js (generated by Webpack) is loaded and executed
- Webpack sees we need to load and executes bootstrap.js
- Bootstrap tells Webpack it needs a file from widgets’. It fetches remoteEntry.js to figure out what.
- It fetches the dependencies.
- Once fetched, bootstrap.js is executed
Sharing dependencies
As many remote applications may need the same dependencies, it doesn’t make sense to install these multiple times. Instead, look at the shared
property of the Module Federation Plugin. If you set this in common across both remote applications, the dependency will be shared between them.
For example:
shared: { react: {singleton: true}}
We load our dependencies asynchronously to prevent an eager consumption error. An example is contained in bootstrap.ts. This follows the same asynchronous loading pattern we see above in the container.
Versioning is handled automatically. If you specify a different version of a package in one application, than exists in the other, and they’re attempting to share, then both versions will be loaded and the correct one is used in the respective applications.
The Module Federation Plugin looks at the shared dependencies, and reconciles them with what is contained in package.json
. If the versions diverge, then both are loaded.
We can prevent this from occurring for packages such as react which rely on there being only one instance running, by passing a singleton property which ensure that only one copy is ever loaded.
Shared module selection can also be delegated by importing package.json
and adding our dependencies. This is purely optional however.
You can see an example of this in the repo:
shared: packageJson.dependencies,
What about the execution?
We want to be able to run our code in isolation, and as part of a container. So how do we handle the assumption of where it will be rendered?
When developing in isolation we can assume that the element with id='foo'
is present. But when running it as part of a container application, how do we know what the id of the element rendering our code will be?
The best pattern for handling this is to wrap our application in a ‘mount’ function which accepts an HTML element or React Element as an argument. This will then allow us to render the code in the correct place.
As you can see in the repo we achieve this in the bootstrap
file:
const mount = (el: Element) => {
ReactDOM.render(<App />, el);
};
Pattern for importing remote applications
If you look in App.tsx
and components/WidgetApp.tsx
then you will see an example of how to include remote applications in the container. We use the useRef
hook to create an element which the app will be injected into, and a useEffect
hook to ensure that we only load it in once:
import React, { useRef, useEffect } from "react";
import { mount } from "widget/WidgetApp";
export default () => {
const ref = useRef(null);
// Pass in our ref and render it once.
useEffect(() => {
mount(ref.current);
});
return <div ref={ref} />;
};
What about communicating between apps?
Ideally, you don’t want your apps to be aware of each other, as that can create issues, but there will be times you need to pass data between them.
There are a few ways of achieving this, but my preferred pattern is to follow react’s example and pass callbacks and state downwards from the container application to the remote applications.
This has the advantage of being explicit in how data flows through the application.
It’s important to avoid your micro-frontend applications sharing state. This then couples them and makes maintenance extremely difficult. At that point, you may as well just have a monolithic frontend, which may be the more appropriate solution.
What are the downsides of micro-frontends?
It’s important to understand when to use, and not to use a micro-frontend. They have tradeoffs and you shouldn’t be tempted to use this pattern just because you can.
Bundle size
The first downside is obvious. We end up shipping more code to the client. It’s very important to be sensitive to this, and I’ve tried to include best practices in my example repo.
As a quick guide, you should:
- Share dependencies wherever possible
- Lazy load components to prevent unecessary code download
- Avoid bundling enormous packages such as moment
None of these things should come as a surprise. Try to keep your dependencies lean, and keep an eye on your bundle size.
Organisation
The next downside is organisational. Whilst it’s great that you can split up code across teams and release autonomously, you can end up with a lack of communication about features, schedules, and code practices.
This can be avoided by good communication and documentation, but it’s worth bearing in mind.
Complexity
Micro-services can appear intimidating if you’re used to dealing exclusively with monolithic architectures. Questions such as how the applications communicate, where state lives, how to develop a good release pipeline, and test components are all common.
Before rushing to implement micro-frontends, you should take the time to fully understand how they work, and try to communicate this with your team. Once everyone is at a similar level of understanding, it’s easier to move ahead.
Conclusion
Frontend software engineering has become vastly more complex over recent years, and that trend is likely to continue.
We are pushing more and more functionality to the client side, with incredibly sophisticated applications. Understanding how to separate your code into modules and splitting their development can deliver real benefits.
Hopefully by the end of this tutorial you now understand:
- The pros of micro-frontends
- The cons of micro-frontends
- The differences in integration approaches of micro-frontends
- How to implement a client-side integration of a micro-frontend
Found this useful? Let me know on Twitter