Independent deployment of micro-frontends with import maps
Tags: javascript, react
Published: 2020-12-11 17:58:55

_Image on the cover is not really related to the article content, it just to catch your attention. It's Berkhamsted castle in UK._

## Intro

In this small post, I want to share our experience that we had related to FE deployments and how we improved it in the end.

In [my company](https://messagebird.com/), we are using an approach that is very popular nowadays, when a big app consists of apps divided by functionality (and usually supported by separate teams) and called micro-frontends (I will use abbreviation **mFE** from now on).

## Approach with mFE's as NPM packages

This is a setup that we had before the change was made.

Each micro-frontend is an npm package that is published to the private registry and later consumed by the "main" app that is composing many of these micro-frontends to become one app and look like a monolith to the user.

<br/>
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/vc5wpichzp4glid0walo.png)
<br/>

So each mFE has its own repo with its own CI pipeline and usually managed by a separate team. The final step of mFE pipeline is publishing a new version of the npm package. To test something on staging you create a canary release package. To revert - you change the version of the package to the previous one and rebuilt the main app.

The main app is built and deployed to the CDN bucket where it is becoming publically available.

Let's assume the developer from the team that manages mFE1 needs to do a really small fix, for example, change button color. He will do a 1 line change and push this code to mFE1 repo:

<br/>
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/uq200x2bm0qgv9lhlwl8.png)
<br/>

**From the image above you can clearly see that this approach has some downsides:**

* slow build (depends on the number of mFE's, but in our case, it was taking almost 30 minutes to build a bundle with all mFE's)
* hard to deploy changes for mFE, every time for a small change you need to rebuild the main app that is taking a lot of time
* staging deployments is pain again because of long waiting times of main app build
* problems with static assets - since only the main app knows where it will deploy all npm packages assets have to be inlined or should have static URL to some other CDN.

So we decided to change the situation to allow teams to deploy their changes without the need to redeploy the main app. 

After creating RFC and analyzing possible solutions to our problems we were left with 2 possible approaches: 
* Webpack module federation
* Import-maps

We discarded the module federation approach because:
* it was coupling us to webpack bundler
* webpack 5 was still in beta at that time
* our POC was not working as expected

## Run time integration of mFE's using import-maps

[import-maps](https://github.com/WICG/import-maps) is a proposal that will allow resolving ES imports directly in the browser.

When you write: 

`import { omit } from 'lodash'`

The browser doesn't know where it should look for lodash source. Import-maps allow us to map `lodash` to some URL with actual code.

Today it is not fully supported by browsers, actually, it is supported only by Chrome and you need to enable a special feature flag to use it. So we had to use [SystemJS](https://github.com/systemjs/systemjs) module loader to support most of the modern browser versions.

Pipelines of mFE's were changed to output system-js compatible bundles and deploy them to CDN instead of publishing npm packages. Also, we created separate repo that holds import maps JSON files with its own pipeline, the purpose of the pipeline in this repo is to update JSON with a new bundle file name when we need to deploy or revert the mFE.

mFE CI pipeline triggers import-maps repo pipeline with GitLab downstream pipelines feature passing the new version filename. This file name is used to update the import-maps JSON with `jq`, committed to the repo, and deployed to CDN. This was mFE CI don't need to have any code related to updating import maps in their own pipelines.

So now to do a small change, like button color we don't need to rebuild the main app, we can build and deploy mFE independently which increased delivery speed to prod almost 3 times.

<br/>
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/2hewa0273ohdon9dhjok.png)
<br/>

When you go to the [user dashboard website](https://dashboard.messagebird.com/) the main HTML contains a reference to import-map and meta declaration of the import-map type: 

```html
<meta name="importmap-type" content="systemjs-importmap">
<script type="systemjs-importmap" src="https://static.messagebird.com/import-maps/mfes.json"></script>
```

And the import-map JSON file itself looks like this:

```json
{
  "imports": {
    "@messagebird/flowbuilder": "//static.messagebird.com/mfes/@messagebird/flowbuilder/messagebird-flowbuilder.9f544594e16f089c026c.js",
    "@messagebird/developers": "//static.messagebird.com/mfes/@messagebird/developers/messagebird-developers.2e56ce54b98984a4302f.js",
    "@messagebird/integrations": "//static.messagebird.com/mfes/@messagebird/integrations/messagebird-integrations.a3b75369872348817097.js",
    "@messagebird/dashboard-conversations": "//static.messagebird.com/mfes/@messagebird/dashboard-conversations/messagebird-conversations.f5db1861c49c7473ae7f.js"
  }
}
```

To resolve mFE app module on runtime we created this custom hook:

```tsx
/** Resolve mFE In-Browser module on runtime */
export function useMfeModule(
  mfeName?: string,
): [Application | null, boolean, Error | null] {
  const [isLoading, setIsLoading] = React.useState(false);
  const [error, setError] = React.useState<Error | null>(null);
  const [mfeModule, setMfeModule] = React.useState<Application | null>(null);
  React.useEffect(() => {
    if (mfeName) {
      setIsLoading(true);
      System.import(mfeName)
        .then((appModule) => {
          setMfeModule(appModule);
          traceCounter('mfe_loading_success', { mfeName });
        })
        .catch((error) => {
          traceCounter('mfe_loading_error', { mfeName });
          console.error(`failed to load mFE module: ${mfeName}`, error);
          setError(error);
        })
        .finally(() => setIsLoading(false));
    }
  }, [mfeName]);
  return [mfeModule, isLoading, error];
}
```

So far we migrated 4 mFE's, and it works very well us. 
Each mFE bundle is published to it's own folder in CDN bucket. Static assets also published in the same folder, and we use `__webpack_public_path__` to set the public path on the fly.

We have an automatic retention policy on the bucket that removes files older than 90 days.

To revert to the previous version of mFE in case of emergency or bad deployment we simply run the previous CI job that updates the link in import-map to the previous bundle version. 

## Summary

###Benefits

* more freedom for mFE teams
* build speed and deployment time now completely depends on mFE pipeline speed
* main app become more detached and independent from mFE's and it's build time decreased almost 3 times
* staging deployment is taking seconds (just updating bundle file name in staging import-map JSON)
* rollback is taking seconds

###Caching

With the previous setup, we were only exposing one bundle split in chunks. So any change in one of the mFE's was causing the creation of a completely new bundle with new chunks. So it was really hard to cache JS.

In import-maps approach, we are using separate bundles per mFE with hash in the filename and they will be cached by the browser independently. So if mFE was not updated for a while - it will be reused from cache instead of downloading.

### Drawbacks

Of course, there are some downsides, the main app bundle becomes smaller, but mFE's bundles are now duplicating some of the dependencies that were deduped during the main app build. We extracted react, react-dom to shared packages, but maintaining a big list of shared packages may become a burden that no one wants to carry. So the total size of assets downloaded by end-user increased. The total size of all JS assets is now twice bigger, but if you keep caching improvement in mind it is not that bad.

### Thanks

I want to say huge thanks to [Joel](https://github.com/joeldenning) for creating such a beautiful website with a very good gathering of documentation related to the microservice architecture of frontend apps. I recommend visiting it if you struggle to understand the terms in this post: [SingleSPA](https://single-spa.js.org/).