An investigation into micro frontends using module federation
Module Federation is a new feature in Webpack 5 that aims to simplify the creation of micro frontends and easily share code between applications. You can view the source code for this investigation here as well as the corresponding demo here.
But first a bit of background….
Micro frontends. Here to stay or the latest shiny thing?
The concept of micro frontends was first introduced in 2016 in the ThoughtWorks Technology Radar whereby a web application is broken down into small manageable pieces that can be developed, tested and deployed in isolation. Similar to the growing trend of breaking monolithic backends into micro-services, companies are now starting to look at this strategy on the front end as well. But do we need this? Or are we just over-complicating our lives for the fun of it?
There have been various approaches to achieving micro frontend architectures such as representing micro frontends as npm modules, web components or even embedding them in an iframe. However, each method above has it’s downsides. For example, in the case of npm modules, deploying updates to individual applications can be a slow and often manual process. When a new version of an npm module is published, teams need to inform each other of this and depending on the size of the application, there can be a lot of time waiting around for builds and tests to finish just to simply bump a version number and update the individual parts of the application.
Module federation promotes itself as being a simpler, more practical method of achieving micro frontends but before we get into that, let’s take at why we might even want this in the first place.
The good stuff
- Iterate quickly and effectively: With micro frontends, an application can be split up into separate parts around a particular business domain and fully owned by autonomous teams in that domain. With this model, each team can develop and release in isolation providing them greater flexibility which allows them to develop and release new features quickly and effectively.
- Reduce deployment risk: Micro frontends are independently testable and deployable. As such we should be able to release an individual part of the system with reduced risk to the wider application as a whole
- Reduce deployment bottlenecks: Since we can deploy independently, changing code in a certain area of the codebase should only deploy the micro frontend associated with that area. Not having to build, test and deploy the whole application should allow teams to release more frequently and with greater confidence.
- Scalability: Individual parts of the application can be scaled up or down as needed, without the need to scale the entire application. Let’s say our search results page is implemented as a micro frontend. If this page starts getting a spike in traffic, then we can easily scale that part up without scaling the entire site.
- Allow incremental updates: With large monolithic codebases, if one team wants to update a shared library like React for example, then all the teams using that codebase need to upgrade at the same time. This can often be a huge, time-intensive task, especially for major version upgrades. With the micro frontend approach, each team can upgrade their part when they are ready, thereby reducing the time to upgrade as well as the associated risk of upgrading the whole codebase in one go.
The challenges
These benefits sound all good and well, but similar to the trend in backend development of breaking monolithic backends into microservices, it does add a certain degree of complexity to the development process as well as raise some additional challenges. For example:
- How to provide a seamless user experience across a collection of micro frontends, with many of them developed in isolation across different teams.
- How to share code efficiently between micro frontends. It is important for performance that the end-user does not need to download duplicate code when navigating across the application.
- How to ensure that you don’t need to deploy multiple micro frontends for a single change. If the boundaries of each micro frontend are not carefully considered, it could result in too much coupling between the individual parts, which will greatly increase the complexity of maintaining and deploying.
- How to ensure that multiple implementations of the same feature or component are not developed. With teams working in isolation, this could reduce the discoverability of what other teams are building, potentially causing them to rebuild the same components over and over again.
Many articles on the web will advise that micro frontends are not always the best choice for your application, and should be carefully considered before adopting. One company spent six months moving to a micro frontend architecture only to find they had a lot more new problems than before they started, only to move back to their original implementation. Furthermore, some say that we should not view the monolithic application as such a terrible thing. Micro frontend architecture might be the perfect choice for very large corporations, with multiple teams working on the same codebase. But if you run a small team and not a tech behemoth then perhaps you should embrace the monolith and make it majestic?
Regardless, even though the Gumtree frontend team is relatively small, we still think that it is a worthwhile experiment to investigate if a micro frontend architecture could work for us.
The requirements
So what do we need to make this work? Well at the very least we want:
- to be able to build, test and deploy individual Gumtree pages or components in isolation
- to be able to load code at runtime from other builds
- to retain server-side rendering capabilities as this is important for page performance and SEO
- to be able to use client-side routing to navigate between separate webpack builds and load them on demand without a page reload
- to not have to significantly alter our existing architecture
- to be able to trial this strategy incrementally to determine whether to adopt
Before diving into a solution, let’s take a look at a high-level overview of our current setup and infrastructure.
Our current setup
This is a slimmed-down overview of how our code gets from source to production. Source code is stored in our mono repo, with separate parts of the application roughly divided up into individual npm workspace packages. The main packages are an Express built BFF server, packages for individual pages or sub apps as well as a shared UI component library that contains the essential UI building blocks of the site that is shared across all pages. The BFF server is responsible for dynamically loading code for a given page and server-side rendering it with an app shell that contains the shared header, nav and footer among other things.
This strategy has served us well, but as the application source code grows in size, there is more chance of bottlenecks in our CI/CD pipeline with more code required to be built, tested and deployed. We would like to be able to take advantage of all the potential benefits listed earlier so long as we do not increase the complexity of our codebase and infrastructure.
Introducing Module Federation
Module federation allows a JavaScript application to dynamically run code from another bundle/build, on client and server.
It is a new feature in Webpack 5 that aims to the simplify creation of micro frontends and easily share code between applications. The central idea is that you can expose any Javascript code (components, utilities, whatever you like) from an application built in Webpack and then import that code into other applications using import statements.
Furthermore, it allows for individual applications to be updated automatically and available to view in the browser as soon as they are deployed, without needing a rebuild of the other applications.
Before getting started, there are a couple of important concepts to familiarise yourself with.
Host: This is the primary webpack build that is initialised when an application loads. It can consume modules from other webpack builds known as remotes.
Remotes: These are Webpack builds that expose modules that can be consumed by hosts.
With this in mind, let’s see if we can use module federation to fulfil our earlier requirements and adopt a micro frontend based architecture.
Our proposal
We will use a pattern whereby the application shell will be an individual webpack build acting as a host. Besides containing the shared header and footer components, it will be responsible for top-level routing, and can load different pages on demand from remote webpack builds, when the Url changes. It also provides the shared libraries that get reused by the lazy loaded-remotes.
In the above diagram, each package in the mono repo now has its own webpack build. The app shell is the host, and each individual page is a remote. Likewise, so is the shared UI library. When the code in a package changes, only that package is built, tested and deployed on its own pipeline. This host application will be updated automatically as soon as the remote application is deployed.
Proof Of Concept
We decided to build a slimmed-down prototype of the above model to see if it could work.
The current prototype consists of 4 separately built applications all living within a single mono repository. The first application is the app shell which is the host. Then there is the home page, item page and results page which act as remotes and can be loaded in by the shell on-demand as the user navigates through the site. The data is supplied by a separate Express application with a GraphQL endpoint.
What’s next?
Currently, the application does not support server-side rendering yet, but this is a must-have for us, and we will need to figure this out if we plan on adopting this strategy. You can view the source code for this prototype here as well as the corresponding demo here. Please stay tuned for the next post in this series where we will go into the source code in more detail and explain how Webpack 5’s module federation plugin actually works.