Micro-Frontends
Do you have a large scale UI that takes way too long to rebuild? Do you have multiple teams and frequently run into code conflicts and other integration issues? Is an app responsible for too many features? Micro-frontends can probably help you here. Micro-Frontends takes the Micro-Services architecture concept from backend engineering, and applies it to frontend development. But how can splitting the UI into multiple frontends help to scale your project?
In my previous job, from 2012 until 2016, I was the lead developer for a multinational company’s UI framework, where our team designed and implemented a micro-frontend architecture. In this article, I’ll share some of the benefits, and things I’ve learned through working with micro-frontends.
What are Micro-Frontends?
Micro-Frontends have no defined framework or API, it’s an architectural concept. The main premise behind micro-frontends, is splitting your app into several smaller apps, each with their own repositories, that are focused on a single feature. This architecture can be applied in many different ways. The architecture can be as liberal as possible, where each app could be implemented with different frameworks, or the it could be more prescriptive, providing tools and enforcing design decisions. There’s benefits and downsides to both of these approaches, and they largely depend on your organisation’s needs.
An important thing to highlight with micro-frontends, despite splitting the app into several projects, they would be integrated together at the end into a Single Page App. To the end user, it would all appear to be one app, not many. Usually, there would be a parent runtime that would handle the lifecycle of each app, so that it gives the experience of a single page application. So you’re not losing anything in terms of user experience by implementing this architecture.
When to use this architecture?
In my opinion, the best approach to splitting an app is to split on unique sets of screens and features. Consider your mobile phone, on your phone, you have different apps with different features. You have a phone app for dialing, a messaging app for texting, and a contacts app for storing numbers. These apps often interop with each other, but they have very distinct purposes so they are implemented as separate apps.
Another example, imagine you were developing an administrative system for managing a college. In such a system, you might have a page for managing staff profiles, student profiles, course details and timetables, distributing course materials, exam results, and so on. Each of these features might depend on each other loosely, but for the most part, they’re standalone features. It would be a perfect candidate project for implementing the micro-frontend architecture.
Why use this approach?
So we know what micro-frontends are, but why would you choose to use such a complex architecture? Here’s a few of the top reasons I believe why micro-frontends are valuable for large-scale development:
Faster Builds
The larger a project becomes, the longer it can take for that project to build. While bundlers such as Webpack and Parcel have went to great lengths to improve the performance of bundling through use of multiple threads and caching, in my opinion, it’s only a stop-gap measure for a more fundamental problem. Even with those performance improvements, as your app continues to grow, your app will progressively become slower to build. Remember that without a good developer experience, it’s difficult to provide a good user experience.
By splitting your app into several different projects, each with their own build pipeline, each project will be very quick to build, regardless of how your system grows. The continuous integration system would also benefit from this, as each project can be parallelised and built indepedently and finally concatenated together at the very end.
Dynamic Deployments
In my opinion, one of the coolest features about micro-frontends, is the ability to deploy new features without the need to recompile anything else, including the runtime. If you have an existing system, rather than shipping and re-installing an entire system, you only need to ship and install the newest feature. This can be incredibly powerful, and opens up many new distribution possibilities. For example, if you want to license specific features of your system, you can split those features off into separate installers.
Parallelising Development
By splitting your UI into separate projects, this opens the possibility of having multiple teams working on the UI. Each team can be responsible for one feature of the system. One team for example could work on the phone app, while another team can work on the contacts app. Each team can have their own Git repositories, and can run deployments whenever they want, with their own versioning and changelogs.
For the most part, these teams don’t need to know anything about each other. There still however needs to be a point of integration between these two apps. Each team needs to define and guarantee backwards compatibility on a public API. This API is typically implemented through the use of the URL.
How to Implement?
This article won’t be providing a detailed approach on how to implement micro-frontends, as there’s several different approaches, but here’s some of the important things you have to consider when implementing this architecture.
Routing and Loading Apps
In a normal app, typically a router with code-splitting support is used. Specific routes are defined, and they have their own import statements. In a micro-frontend architecture, this isn’t scalable. You don’t want to be in a situation where you have to define routes for every single app/feature in the system. This would make it difficult to deploy new features. The approach I would use instead, is to have the runtime handle instantiation of apps by listening to the URL:
onRouteChange (route) {
// Assuming routes are "/<app_name>/<internal-app-url>".
let parts = route.split('/');
let app = parts[1];
let app_url = parts.slice(2).join('/');
if (this.isRunningApp()) {
this.suspendCurrentApp();
} import(`/${app}/main.js`).then(app => {
this.startApp(app, app_url);
});
}
In the above code snippet, the runtime is only concerned about the first part of the URL, which we are assuming maps to the name of a folder on the system containing the code for that app. As we could already be running an app, we suspend that app first. We then import the code from a predictable folder structure, and then once loaded we start it passing the rest of the URL.
Bundlers might get confused by that code snippet, and might throw an error or warning saying that it cannot find the file. You’ll need to configure your bundler to ignore that import statement.
App Lifecycle
As noted in the previous section, apps can be started, suspended, resumed, and exited to clear up resources. As such, you might want to consider implementing a lifecycle, similar to those found on mobile operating systems:
class MyApp extends Application {
constructor (args) {}
onAppSuspended () {}
onAppResumed () {}
onAppQuit () {}
}
Communication between Apps
Developers often asked me how to pass data between multiple apps. In a normal standalone app, typically we pass data as a series of props into components. That doesn’t work here, because each app/feature doesn’t have direct access to each other’s implementation anymore.
For simple data, the URL can work perfectly fine here. The team responsible for an app/feature would implement a public URL API that they can guarantee backwards compatibility for. It’s similar to how apps communicate on operating systems such as Android, where you can register custom URL handlers with different intents. The operating system intercepts these URLs and loads the corresponding app passing in the data. That same principle can be used in micro-frontends. The runtime will intercept the URL, load the app, and pass the rest of the URL data into the app.
However, there can be times when you need to pass data that’s incredibly complex and just can’t go into a URL. There’s still plenty of alternative mechanisms for apps to communicate. One such approach, is to temporarily store blobs of data onto a server, and using a temporary ID in the URL which an app can use to query that blob of data. This is more complicated, and requires careful management of the data on the server, but it offers more benefits beyond just simply passing data, such as persistence in the event of an accidental refresh or browser crash.
Sharing Libraries
One common issue that developers worry about with the micro-frontend architecture, is wasteful use of resources, with each frontend importing it’s own version of a framework. If using bundler defaults out of the box, yes, this will be a problem, but it doesn’t have to be.
My personal recommendation, is to use shared libraries. These libraries would already be pre-installed on the system, and would be importable by all applications. Here’s an example of a folder structure you could use when deploying libraries onto a system:
libraries/
preact/
8/
10/
components/
1/
2/
What are the numbers? Those are the major versions of those libraries. If a library is following semver, by definition, it is backwards compatible if it has the same major version number. When an app wants to use a library, it would specify the major version it wants to use, rather than a specific minor and patch version.
Benefits to this approach:
- Caching — It’s more likely that your browser cache will be better utilised, as apps will be referencing the same library files, rather than multiple specific versions which would all have to be separately cached.
- Updating—With this approach, if a library has a bug fix or there’s a slight UX change, all you have to do is deploy the new version of the library, and all of the apps, without recompilation, would automatically use the latest version because they’ve only specified the major version.
What about Iframes?
Don’t use them. Although the sandboxing might seem like a great idea, iframes can be a nightmare to deal with when it comes to navigation, and messaging with the parent frame.
Instead, run all of the app logic inside the parent runtime. This would mean that you would have to be careful about global variables and CSS, but with strong linting rules, and useful helper functions, this shouldn’t be cause for much concern.
In most apps, normally global variables are not too much of a problem. Might not be the cleanest thing to do in a lot of cases, but it wouldn’t have many side-effects.
In the micro-frontend architecture however, globals have to be carefully controlled. Globals doesn’t only refer to variables or state, but it can also include things such as window/document event handlers, requestAnimationFrame loops, persistent network connections, anything that can be actively running despite the app no longer being in the DOM. It can be incredibly easy to forget that these things can leak, and that they require proper tear downs.
Conclusion
Micro-frontends are not for every project. I believe for the vast majority of projects out there, code-splitting is likely more than enough. The micro-frontend architecture is more suitable for large enterprise-level applications with swathes of functionality. When starting a project, consider how large the project might end up becoming. If you feel like the project is going to have inevitable scalability issues, consider using this architecture to solve that problem.
Thanks for reading!