In recent years, microfrontends have become a popular approach for developing web applications. This methodology involves dividing a larger application into smaller, more modular components, allowing for independent development, deployment, and scaling. The approach can result in faster development times, better scalability, and more maintainable code.
In this article, we will discuss our experience working with microfrontends on a project for a media management client. Specifically, we will talk about how we used the single-spa library to divide the platform into smaller, independent components and the benefits this approach brought to the project.
Our team was engaged in improving a platform designed for the collaborative creation, management, and distribution of digital news. The client, a media management company, needed a more efficient way to develop and deploy new features for their platform. We integrated the software application with the single-spa library to address this challenge and divided the system into microfrontends.
By adopting a microfrontend approach, we were able to simplify development and deliver new features to the client faster than before. We also saw improvements in scalability, maintainability, and code reusability. Here is our experience working with microfrontends, the challenges we faced, and the benefits we gained.
When the client came to us, the initial state of the platform was as follows:
- Angular 11;
- Application (further Main-app) with over 29 modules inside, which uses more than 20 private libraries;
- Ivy is disabled because some of the 1st and 3rd party libraries are incompatible with it;
- Angular Router has only one route and custom handler (internally called Workspace Engine) for dynamic navigation. Page layout is retrieved from a server on runtime, and the URL always stays the same.
- No state management framework — all caches with calculated data, etc., were stored in multiple RxJS Subjects in Angular services;
- A lot of services are provided in the root injector and used in almost all modules. Those services are subscribed to shared stateful observables, resulting in everything being tightly coupled;
- Almost all internal libraries are in separate monorepos handled by yarn workspaces;
- There are tight budgets, as much of it another team has already spent on early microfrontends investigation.
Accordingly, the goals of our participation in the project were:
- Split one large angular application into separate micro-apps and build a microfrontend architecture using a single-spa framework with their dedicated repositories and CI/CD pipelines;
- Provide the ability to change applications’ locations on the fly without changing code.
We have identified the steps to achieve these goals:
- Investigate single-spa, single-spa-angular, import maps, and SystemJS;
- Split components (modules) into micro-apps gradually without breaking the whole application;
- Ensure smooth transition of a module to micro-app without blocking other teams responsible for it;
- Try to make it as framework-agnostic as possible.
Investigating and Solving the Initial Hurdles
single-spa and single-spa-angular
As with most of the open-source libraries, we faced issues of poor and irregular support of these libraries. Single-spa-angular supported Angular 12 when Google released Angular 13, and it still fits our needs as Angular 11 was widely used in our projects. Going a bit forward, the situation hadn’t changed when Angular 14 was released and could have blocked our further updates to the next Angular versions when the time would come. Things changed when one Angular expert, the member of Open Source community, added support up to the latest Angular 15.
Unintended Use of single-spa
single-spa is positioned as a router for microfrontends. It’s recommended to register a micro-app as an application, and the application should be tied to the URL route. This concept does not fit our needs as we have only one route but multiple applications. We had to keep going with single-spa. Fortunately, it has a more advanced concept called parcels. It lets developers manually mount and unmount the application, so they are not dependent on the router anymore but responsible for its lifecycles.
Import Maps is a WCAG proposal to control the URLs from which the browser would fetch a dependency from ES module import. This mechanism allowed us to accomplish our goal of dynamically changing applications’ locations. It can be rewritten and redeployed on the fly with the help of a CI/CD environment.
When we started our work, the Import Maps proposal was not supported by the browsers, so we had to use either es-module-shims or SystemJS. The single-spa team highly recommended it because they were also maintaining this module loader. At the time of writing, it is still not adopted by all browsers except Chrome.
We were also considering using webpack Module Federation, which suited better to our case, and we could not avoid comparing it to SystemJS during development.
SystemJS was built way before the ES Modules were introduced and it is one of the options to bring modules to legacy browsers such as Internet Explorer. That means we needed to build dynamically loaded modules in a SystemJS module format, which brought such drawbacks as large module size and performance drop and could become a bottleneck in further development. Webpack didn’t have this problem, so we could use the preferred module type without any hurdles.
Module Federation is a webpack plugin for microfrontend implementation that enables us to build modules separately and form a single application on runtime. Though Module Federation seems to be just a build-time solution, forcing to define other modules and their URLs in webpack config. It can work without these definitions and load modules via URLs known only on runtime, just like the Import Maps.
Conceptually, it fitted better to our initiative than single-spa. Nonetheless, there were multiple reasons we started to use single-spa though keeping in mind possible migration to Module Federation. The reasons were:
- The business spent a year on a single-spa investigation with another team for angular microfrontend app development. They aimed to start splitting the application, and they didn’t want to spend any more resources on another discovery phase;
- Angular 11 was still using webpack 4, while Module Federation was released in webpack 5;
- In order to work with webpack 5 natively, we needed to update to Angular 12, which forces to use the Ivy rendering engine in applications. We still had many libraries and app parts that were incompatible with it, which could bring a lot of unpredictable regressions throughout the application.
We were able to test webpack 5 because, as we used yarn, we forced Angular to use it by “resolutions” config. But in the end, we faced some troubles with single-spa-angular itself and HMR, which slowed down development for some teams and their modules, so we had to revert it.
Multiple Instances of a Single App
In our project, users can open multiple instances of the same component in the same workspace. There was no problem with the requirement before microfrontend migration when dynamic workspace components became micro-apps. So, single-spa-angular did not support multiple instances of one micro-app on the same page, and when we tried to add a new instance of an app via single-spa parcels to the page, it would replace the existing one in the DOM.
We have created an adapter component for Workspace Engine that continued to work with components, but under the hood, the adapter dealt with mounting and unmounting the micro-apps. It also allowed to utilize engine’s ability to create container elements when needed, thus making it empty, and nothing is overwritten on the new instance mount. That’s because it rewrites everything inside the container element.
Debugging is an essential process for developers, including instruments for identifying bugs and their sources. Imagine DevTools started to crash every time you opened them, making debugging nearly impossible. That’s what happened to our micro-apps when Chrome 102 was publicly released. Another Chrome update stopped downloading Source Maps. We couldn’t identify the reason for such behavior, but everything surprisingly got fixed with Chrome 106 DevTools.
Source Map Namespaces
Naturally, different applications might have files with the same name. While it is not a problem in a single application, we faced a Source Map name clashing issue as we split several applications and utility modules. Webpack and Angular CLI use the default Source Map namespace if you do not set it explicitly in the configuration.
So, when a developer has different Source Maps with the same file path, they will see only one Source Map from one module in DevTools at runtime, and it becomes difficult to debug other modules as it makes them debug minified bundle. To improve the developer experience, we recommend configuring the namespace in webpack’s SourceMapDevTool plugin in every application and utility module. As a result, it becomes able to see each module source as a separate folder tree in DevTools.
Open-source and Documentation
Documentation is undoubtedly important, but it is time-consuming to cover and keep every topic up-to-date, so some docs miss nuances and little details. That’s the case for single-spa and SystemJS. We found most of the answers in its source code rather than documentation. We strongly recommend reviewing and understanding library sources used to check concerns and hypotheses when documentation does not help.
From Hurdles to Progress: Navigating the Main Work Process, Bug Fixes, and Improvements
From the very start of the project, we tried to be as close as possible to the single-spa recommended architecture and setup . However, we couldn’t follow them most of the time due to limited time and the tight coupling of application modules.
Root application, or as single-spa docs call it, root config, is a framework agnostic entry point to our microfrontend world that:
- bootstraps single-spa;
- downloads import map and bootstraps SystemJS with it;
- orchestrates the applications.
While the first points fit our goals, the latter was a problem as our application (the Main-app) had its own orchestrator – the Workspace Engine. Needless to say that it was a foundation for the whole application, and it was tightly coupled to Angular component lifecycles itself. Rewriting this functionality would have taken lots of effort and could have introduced many breaking changes. So the orchestration flow we ended up with was to register and bootstrap Main-app and delegate further routing concerns to it by making it able to mount and unmount micro-apps and not components as it was before.
Later, the root app got some new responsibilities as an entry point:
- bootstrap utility modules;
- share common styles.
We developed a plan for micro-apps splitting:
- Identify core module that should be migrated as micro-app and its feature modules.
- Extract feature modules from the Main-app to libraries.
- Migrate core module as micro-app.
- Use extracted module like a lib in micro-app.
- Replace the usage of core module in Main-app with the micro-app.
All of the steps would have worked if the core module doesn’t depend on other parts of the Main-app, but in most cases, it depends. Splitting to micro-apps was forced, so we had no time to move or refactor multiple places in the app to make them less coupled.
Under these conditions, we chose a way with shared angular services between micro-apps.
Angular Shared Services Mechanism
The Angular shared services mechanism is a similar mechanism (like for libraries) with the forRoot() method of the module. We created an instance of service in Main-app and registered it in the shared utility library. Then micro-app got it from shared lib, and via angular providers replaced the instance for lib module injector. This mechanism brings several drawbacks but allows to start micro-apps splitting faster.
We have identified several problems with shared services:
- tight cross micro-apps coupling;
- boilerplate in application modules;
- libs versions mismatch, which brings injection and runtime errors;
- version hell;
- duplication of libs in apps which brings large bundles;
- sources duplication in devtool and confusing debug flows.
In any case, we strived to reduce the number of such services.
According to each shared service usage and logic inside, we needed to apply the following approaches to refactoring in order of possibility and less time-consuming:
- If possible, make a service standalone, refactor to allow micro-apps to use it separately, and be aware not to multiply network calls to the backend in a case with multiple service instances.
- Refactor direct calls of service to events-driven subsystem using event-utility-lib.
- Extract shared state or full logic to shared utility library related to micro-app or separated app parts.
For example, services of profile-micro-app can be refactored with profile-utility-lib usage, and some workspace engine services can be refactored to workspace util lib., etc.
Shared Utility Modules
We realized we couldn’t follow the concept of a standalone micro-app from the original microfrontends idea, and we needed to store these shared services somewhere, share configs, cache, and state between micro-apps.
The single-spa documentation here helped us with utility modules. In general, there are small pieces of logic (a couple of functions, a few native js services) that are provided at runtime from Root-app to other micro-apps via webpack externals.
As a result, we created a few utility libs to share, which purposes were:
- mount/unmount parcels, store parcels cache, manage the lifecycle of micro-apps;
- store shared services, provide a special Angular decorator to check – is there more than one service instance exists;
- store angular NgZone to keep change detection works between micro-apps;
- store common backend clients to share cache and avoid unnecessary backend calls;
- provide an events API between micro-app to reduce shared services usage;
- keep micro-app-specific logic and provide micro-app open API for other apps.
Integration and Usage of Micro-apps
After we extracted the module and created a micro-app, we could add it to the Main-app. It was important to remember to update import-map.json in Root-app to show system.js where to get the micro-app bundle. If we would replace a regular angular component, we would just need to put an empty <div> in its place and tell single-spa to mount a parcel of the app to this empty element.
But we also had dynamic components in the workspace section, which micro-apps should also replace. In that case, as the first steps, we separated regular dynamic components in the workspace from micro-apps in data that comes from the workspace from the backend.
Now they both have an Id and a type field which helps to determine where we should use a predefined component or dynamic micro-app. In both types, we are loading dynamic component to the workspace area, but for micro-apps, it is the same component that works with the shared parcel utility module and just wrap a micro-app instance.
Here is a diagram of how the mounting flow works in our environment:
We used a trick of import-map to open different components coupled by common logic from one micro-app. It is possible to use a different import-map key for the same micro-app bundle URL. Then on micro-app bootstrap, we got this key value and ran the necessary code. It’s worth noting that if there are multiple instances of the same app, even with different components inside, code like static fields, constants, and environment variables are the same for all instances.
Following this way, we extracted ten micro-apps projects with fourteen main components inside a single-spa environment, fixed hundreds of bugs that single-spa brought us in process, implemented multiple fixes for its internal problems, improved bundle sizes and performance of modules loading, developed the Root-app from a single angular application in the beginning that became an aggregator of multiple single-spa micro-apps in the end:
A Summary of Our Microfrontend Journey
When creating multiple independent apps from scratch, built on different frameworks, using different routes for different app parts, perhaps with old browser support, the choice can be a single-spa environment.
Suppose a large ready mono framework application has multiple modules, tightly coupled components and services, and a lot of shared stuff between application parts. In that case, starting to build a microfrontend application with the webpack module federation approach might be much easier.
Or, when combining different concepts, it is necessary to be prepared to get the downsides of both options.
If you’re interested in adopting a micro-frontend approach for your web application development project, our team would be happy to provide consultation and share our expertise. We have extensive experience using microfrontends to improve development efficiency and deliver high-quality results to our clients. Contact us today to learn how we can help you achieve your web application development goals.