Microfrontends in Mobile with React Native
Mo Khazali10 min read
Introduction
This article is meant to be an exploration of the state of creating Microfrontends (MFEs) for native mobile apps in 2023. It covers the history of MFEs, giving a brief intro, and then looks into how React Native’s architecture can theoretically make MFEs possible. We look at a naive implementation of MFEs in a brownfield app, using CodePush to handle over-the-air (OTA) updates. Finally, we investigate what solution already exists, some of the challenges around MFEs in mobile, and what we can pragmatically achieve today.
This article is not making a case for using MFEs in general, since the architecture is only suitable for large scale organisations that have several teams and are facing very specific scaling issues.
From Monoliths to Microservices to Microfrontends
The story of microfrontends, ironically, starts from the backend. In the early 2000s, people were starting to get sick of having these huge monolithic backends that would contain everything under one umbrella. Having massive backend apps meant that both the app and also the development teams building the app were slowed down. The cognitive load to work on a monolithic backend that may have business logic for multiple features (including ones that you may never touch or interact with) was massive.
Naturally, teams started looking at breaking these down into smaller isolated “services” that could each contain their own logic. If they needed to communicate with other “services”, they could do so via APIs or some type of event bus.
Hence, the microservices architecture was born.
Fast forward around a decade - our applications have shifted more and more logic into the frontend, and similar scaling problems started to surface for large scale projects that had multiple teams working on them. Hence, the concept of Microfrontends was born for the web:
“An architectural style where independently deliverable frontend applications are composed into a greater whole”
If you’re new to the concept of MFEs, I’d highly recommend watching Luca Mezzalira’s introductory talk about them. It gives a good overview of how the architecture works.
The classic example that people look at for MFEs would be the amazon website.
You could break this frontend down into multiple well defined isolated bits. The header and navigation bars could each live in their own MFEs, while the promos could sit in their own MFEs.
Benefits of MFEs
- You break down teams into smaller feature-based verticals. This means that each team has less context to deal with at any given time.
- Each of these teams get to choose their own tech, and you’re not bound by any of the decisions and tech debt from other bits of the code.
- Teams can release on their own cadence, with their own release processes and cycles. This is usually the primary reason people use MFEs: large teams often have longer release cycles, and this slows down work in general. By giving each team the power to control their own releases, teams can release more frequently.
Disadvantages of MFEs
Of course, the major disadvantage is the added complexity. Much like in the world of microservices, your overall app now has several different moving parts that are being managed by different teams. Effectively, this means that you have a larger surface area for failures.
On top of that, you now need a host layer that can manage all of the different MFEs and combine them together into one large app. This host layer can end up being quite complex and difficult to manage.
Microfrontends in Mobile
We can take a similar approach of breaking down apps into smaller feature based modules quite easily. This is already common practice in larger organisations that have large scale apps. If we were to imagine the Amazon app, it could be broken down into multiple “microfrontends”, each residing within their own screens/tabs.
In this case, we’ve broken down the different screens into separate MFEs or modules.
This is all fine, but the concept of microfrontends falls short in the mobile space when we look at independent releases. Native mobile apps need to be fully bundled and released via an app store. This means that even if development is taking place in independent workstreams, the different modules will eventually need to be brought together and bundled before they can be shipped to users.
The React Native Advantage
React Native can sidestep the app store deployment block. This is one of the advantages to the architecture of React Native being split into JS & Native layers. Since JS code is being interpreted at runtime, we could simply update the JS portion of the code using an over-the-air update (OTA). You can update any part of your JS code, so long as it doesn’t need access to new native layer dependencies, effectively circumventing the app store submission process for updating your app. This results in faster iterations, quicker bug fixes & releases, and ultimately, happier users (& devs).
Having access to OTA updates is a good first step towards achieving independent release cycles.
OTA Tools
A popular tool to implement over the air updates is Microsoft’s CodePush. This works great for RN apps with a single bundle. However, using multiple bundles doesn’t work in CodePush, as it assumes that there is one deployment key per JS bundle. The internals of CodePush on the native layer check to see if the date of the newest bundle available on the cloud is newer than any existing bundles.
NSString *packageFile = [CodePushPackage getCurrentPackageBundlePath:&error];
NSURL *binaryBundleURL = [self binaryBundleURL];
// .....
NSString *packageDate = [currentPackageMetadata objectForKey:BinaryBundleDateKey];
NSString *packageAppVersion = [currentPackageMetadata objectForKey:AppVersionKey];
if ([[CodePushUpdateUtils modifiedDateStringOfFileAtURL:binaryBundleURL] isEqualToString:packageDate] && ([CodePush isUsingTestConfiguration] ||[binaryAppVersion isEqualToString:packageAppVersion])) {
// Return package file because it is newer than the app store binary's JS bundle
NSURL *packageUrl = [[NSURL alloc] initFileURLWithPath:packageFile];
isRunningBinaryVersion = NO;
return packageUrl;
}
This implementation has one stored binaryBundleURL
for each app, and compares any new CodePush bundles with the existing bundles and packages. This means that the first bundle in the app will load correctly, but any additional bundles will break the CodePush update, giving us the ominous error message:
An update is available, but it is being ignored due to having been previously rolled back
This is because when the second bundle is opened, it tries to update the existing “bundle”, not checking to make sure these are the same bundles in the first place. Ultimately, the update fails, and we get deadlocked and our CodePush gets broken.
So, as it stands, it won’t be possible to update multiple bundles.
Can we change this?
I took some time looking at the source code, and was able to get a rough proof of concept working with a Swift app that had two separate React Native bundles:
In the most basic form, I modified the conditional above to add extra checks on the resourceName
being supplied and only compare versions of the bundle if the resource names match.
There needs to be more modification on this since there’s some hash & metadata checks that are happening behind the scenes, but this was the simplest way to get a working POC.
The code I used is very WIP (and I don’t have full faith in it) so I’m working on cleaning this up and plan on opening up a PR into CodePush for this functionality.
Now even if this mechanism was perfected, there are still some issues with using multiple bundles:
- You’re inevitably duplicating parts of the bundles that both RN apps need. This leads to larger bundle sizes and unnecessary code duplication.
- In this case, we’re needing to use two different instances of the bridge, which is a massive resource hog.
- We’ll need to handle the native layer integration of these bundles (no purely RN app)
This is sub ideal, albeit, it can be a potential intermediate solution for large scale native apps migrating to React Native incrementally.
Webpack for React Native?
Webpack have a very linked history with MFEs. This is largely due to the Module Federation plugin, which is the most mature implementation of MFEs. It’s a rich ecosystem that brings all the right tools to the MFE world.
In the React Native space, the Callstack team have been working on a port of Webpack that works with React Native. This brings all of the Webpack ecosystem with it, including Module Federation. This is a very cool project and opens up the door to building large scale native apps with MFEs.
However, the flip side is that you diverge heavily from the core React Native stack by replacing Metro. This introduces a host of added complexity to a React Native project, including modified hot reloading, slower performance, and more complicated web support (you need to use Webpack directly for RN for web projects).
This becomes a larger tradeoff since the rest of the RN (and web) ecosystem seems to be moving further away from Webpack. Expo has recently announced that they no longer suggest you use Webpack for RN for web, and they suggest using Metro instead - implying that Webpack support will be dropped eventually.
In an ideal world, we can have better Async Chunk support in Metro for code splitting - and maybe perhaps some module federation-like mechanism that’s native to Metro. This is not an out-there posibility, since the Vite community have created a Vite/Rollup plugin to support Module Federation.
General Challenges in Microfrontends for Mobile
Let’s take a step back: even with native Module Federation support in Metro, there will be significant challenges to effectively using MFEs in RN apps:
- Native dependency changes are not possible with OTA updates. As a result, teams will need to sync their release processes when introducing any new native dependencies. This needs to be well thought out and planned, which can add more organisational processes and bureaucracy.
- MFEs will need to have pinned dependencies to one another. Each team will need to be using the same dependencies, since the bundles will have access to a single underlying native layer. This takes away from the “tech-agnostic” approach that you have with web MFEs.
What can we learn?
Recently we had a client approach us for a pretty large project - building a full scale news platform that contains several different types of media (videos, audio, and text). Each media type can have multiple layouts, and the complexity of the application required multiple teams working on it at once.
This led us to consider a feature driven approach, where we broke down the teams into vertical modularised silos. Each team would be working on a specific feature. They could run their features independently from the main app inside of a sandbox
environment. This meant that each team could independently develop without stepping on each others’ feet.
While we’re not using full MFEs, this approach is a pragmatic way to achieve some of the benefits of splitting an application into smaller features.
I was pleasantly surprised to see a talk at RNEU this year by Sandra Jurek about Chase’s UK Mobile app architecture - they follow a similar pattern and they’ve been able to improve their development times, clearer distinctions in team ownership, and better independence of teams.
Taking these concepts of modularisation & breaking things down into packages, we’ve been able to see similar improvements and we’re excited to explore this space even further.
Feel free to reach out
Feel free to reach out to me on Twitter @mo__javad. 🙂