The Secret Sauce of Micro Frontends: Preventing bundle bloat using SystemJS and Single-SPA

Faizal Vasaya
Globant
Published in
10 min readSep 29, 2020

Prerequisites

This blog requires you to have a theoretical understanding of what micro frontends are and why they even exist. In case the word micro frontend has hit your brain cells for the first time, I would request you to roll your brain over this video by Joel Denning.

A scenario, since we are humans

Imagine a micro frontend ecosystem wherein multiple Angular applications are combined into a single page to create a seamless user experience as demonstrated below.

A scenario for Angular micro frontends

In the above scenario, Angular Application 1 and Angular Application 2 are loaded in a single browser tab. This raises a question as to how many times the Angular framework/compiler and other common libraries have been loaded in the browser. You guessed it right, the answer is twice. This is because webpack bundles up all the dependencies in while bundling the application and the same is loaded in the browser each time an Angular application loads.

Let’s see all this in Action

In order to demonstrate this, let’s set up a micro frontend ecosystem in our system. I would urge you to code along as it would help you foster your learnings from this blog.

Step 1 : Installing Angular CLI

Let’s install the dependencies required to create a micro frontend ecosystem using single-spa framework, Angular and SystemJS.

Run the following command in the directory where you want to set up a micro frontend ecosystem:

yarn global add @angular/cli@8.3.29

or

npm install --global @angular/cli@8.3.29

Step 2 : Creating the first Micro Frontend : angular-application-one

Since the angular command line has been installed let’s create an angular application.

Run the following command in the directory where you want to set up the first application of the micro frontend ecosystem:

ng new angular-application-one --routing --prefix ng-app-one

When asked for a stylesheet format, choose CSS.

Step 3 : Adding single-spa support: angular-application-one

In order for the angular CLI generated application to support a single-spa micro frontend ecosystem and to be loaded as an inbrowser module, we need to follow a few steps as mentioned here.

Navigate to the angular-application-one folder, and run the following command:

ng add single-spa-angular

It should ask you a couple of questions, answer it as follows:

Next, follow this link and configure routes in the angular-application-one.

Step 4 : Creating another micro frontend: angular-application-two

Run the following command in the directory where you want to set up the second micro frontend ecosystem (use the same directory as it was used in Step 2)

ng new angular-application-two --routing --prefix ng-app-two

When asked for a stylesheet format, choose CSS.

Step 5 : Adding single-spa support: angular-application-two

Navigate to the angular-application-two folder, and run the following command:

ng add single-spa-angular

It should ask you a couple of questions, answer similarly as it was done in step 3.

Next, follow this link and configure routes in the angular-application-two

Step 6 : Creating the single-spa root application

Install global dependencies for the create-single-spa CLI.

yarn global add create-single-spa@1.12.6

or

npm install --global create-single-spa@1.12.6

Run the following command in the directory which was used in step 2.

create-single-spa

It should ask you a couple of questions, answer it as follows:

Once the installation is completed, your current directory structure should look like this:

code/ 
angular-application-one
angular-application-two
wrapper-application

In the wrapper-application install the following dependencies.

yarn add core-js

or

npm install --save core-js

Step 7 : Running micro frontends

Run the following command in the angular-application-one directory

yarn serve:single-spa

The above command should run the angular-application-one on http://localhost:4200/

Well, wait, don’t go to the browser right now, you won’t be able to see anything as it is an in-browser module to be used along with single-spa and not individually.

Next, go to angular-application-two/package.json and modify the build:single-spa and serve:single-spa as follows:

"build:single-spa": "ng build --prod --deploy-url http://localhost:4300/","serve:single-spa": "ng serve --disable-host-check --port 4300 --deploy-url http://localhost:4300/ --live-reload false"

Run the following command in angular-application-two directory

yarn serve:single-spa

The above command should run the angular-application-two on http://localhost:4300/

Well, wait, don’t go to the browser right now, you won’t be able to see anything as it is an in-browser module to be used along with single-spa and not individually.

Step 8 : Adding micro frontends to the ecosystem

Next, go to wrapper-application/src/mfe-root-config.ts and add the following line of code to register the micro frontend applications: angular-application-one and angular-application-two.

registerApplication({  
name: '@mfe/ng-app-one',
app: () => System.import('@mfe/ng-app-one'),
activeWhen: ['/'],
});
registerApplication({
name: '@mfe/ng-app-two',
app: () => System.import('@mfe/ng-app-two'),
activeWhen: ['/'],
});

And then add an import at the top as follows:

import 'core-js/proposals/reflect-metadata';

mfe-root-config.ts file should now look like this:

import { registerApplication, start } from 'single-spa';
import 'core-js/proposals/reflect-metadata';
registerApplication({
name: '@mfe/ng-app-one',
app: () => System.import('@mfe/ng-app-one'),
activeWhen: ['/'],
});
registerApplication({
name: '@mfe/ng-app-two',
app: () => System.import('@mfe/ng-app-two'),
activeWhen: ['/'],
});
start({ urlRerouteOnly: true,});

Next, go to wrapper-application/src/index.ejs and add the following code after line number 55:

"@mfe/ng-app-one": "http://localhost:4200/main.js",          "@mfe/ng-app-two": "http://localhost:4300/main.js"

In the same file, add the following code within the main tags in the body

<main>
<div id="single-spa-application:@mfe/ng-app-one"></div> <div id="single-spa-application:@mfe/ng-app-two"></div>
</main>

Next, comment out the meta tag specifying content security policy in the same file.

Also, uncomment line 67 which loads zone.js in the ecosystem in the same file.

Step 9 : Running the wrapper application

Run the below command in the wrapper-application

yarn start

Visit, http://localhost:9000/ in your browser and boom you should see two angular applications running on different ports but loaded together in the same browser tab. You have your minimalistic micro frontend ready.

Pat yourself on the back if you reached here and have served the application correctly. Good Job mate !!

Step 10: Detecting the bloat

Notice the current size of the builds for angular-application-one and angular-application-two that is loaded in the browser by going to the network tab, reloading the application and noting down the current size of the main.js files which is loaded from localhost:4200 and localhost:4300. You should see something similar to this.

Size of angular micro frontends before optimization

Well, loading 8 MB of JavaScript to render the CLI auto-generated application isn’t acceptable. One of the major reasons behind this size is the angular compiler and the other components of Angular framework that get loaded in the browser each time a micro frontend is loaded.

Here, you may say, Meh! This is how it works and let it go to production or as Mahatma Gandhi said, “Be the change you wish to see in the world”.

Is this possible?

The only way we could avoid this bloat is by loading the Angular compiler and other framework components/libraries only once. And the same could be passed on to all micro frontends loaded in the ecosystem. This would allow micro frontends to load the application-specific code and the dependencies could then be provided by the ecosystem. But how?

Webpack externals to our rescue. Externals allow us to instruct Webpack to avoid bundling a given set of dependencies in the final bundle with an assumption that these dependencies will be provided by the consumer i.e during runtime when the application executes in the browser.

Roll, Camera, Optimization.

Step 1 : Eliminating dependencies: angular-application-one

Open angular-application-one/extra-webpack.config.js and add the following code after the singleSpaWebpackConfig const has been defined and before the return statement:

singleSpaWebpackConfig.externals = {    
"@angular/animations": "@angular/animations",
"@angular/common": "@angular/common",
"@angular/compiler": "@angular/compiler",
"@angular/core": "@angular/core",
"@angular/forms": "@angular/forms",
"@angular/platform-browser": "@angular/platform-browser", "@angular/platform-browser-dynamic": "@angular/platform-browser-dynamic",
"@angular/router": "@angular/router",
rxjs: "rxjs",
"rxjs/operators": "rxjs/operators",
tslib: "tslib",
"single-spa": "single-spa",
};

Stop the running angular-application-one app and serve it again using the following command:

yarn serve:single-spa

Visit, http://localhost:9000/ once again by having the network tab opened.

You should notice the main.js file loaded from localhost:4200. Below would be your findings:

Size of angular-application-one micro frontend after optimization

Note the difference in the size of the main.js file highlighted in blue. The file size before optimization (Webpack externals) was around 4MB which has now reduced to a mere 75KB. Well, that’s the secret soup of Webpack externals, however, this magic has left our application invisible as you may notice we are not able to see the angular-application-one in the browser anymore. Let’s put the last piece of the puzzle next.

Step 2 : Loading external dependencies as in browser modules

Goto line number 35 in wrapper-application/index.ejs. The script tag that we see there is where we would add all the external dependencies as in-browser modules.

Add the following after the “single-spa” dependency. The script tag should look like this.

<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.6.0/lib/system/single-spa.min.js",
"@angular/common": "https://cdn.jsdelivr.net/npm/@angular/common@8.2.14/bundles/common.umd.js",
"@angular/core": "https://cdn.jsdelivr.net/npm/@angular/core@8.2.14/bundles/core.umd.js",
"@angular/platform-browser": "https://cdn.jsdelivr.net/npm/@angular/platform-browser@8.2.14/bundles/platform-browser.umd.js", "@angular/platform-browser-dynamic": "https://cdn.jsdelivr.net/npm/@angular/platform-browser-dynamic@8.2.14/bundles/platform-browser-dynamic.umd.js", "@angular/router": "https://cdn.jsdelivr.net/npm/@angular/router@8.2.14/bundles/router.umd.js",
"rxjs": "https://cdn.jsdelivr.net/npm/rxjs@6.4.0/bundles/rxjs.umd.js", "rxjs/operators": "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@6.5.4/system/rxjs-operators.min.js", "@angular/compiler": "https://cdn.jsdelivr.net/npm/@angular/compiler@8.2.14/bundles/compiler.umd.js",
"tslib": "https://cdnjs.cloudflare.com/ajax/libs/tslib/1.13.0/tslib.min.js" }
}
</script>

Now, stop the currently running wrapper-application and serve it once again using the command:

yarn start

Now, you should be able to see the angular-application-one running in the browser using shared dependencies whereas the angular-application-two using its own dependencies.

Step 3 : Eliminating dependencies: angular-application-two

Open angular-application-two/extra-webpack.config.js and add the following code after the singleSpaWebpackConfig const has been defined and before the return statement:

singleSpaWebpackConfig.externals = {
"@angular/animations": "@angular/animations",
"@angular/common": "@angular/common",
"@angular/compiler": "@angular/compiler",
"@angular/core": "@angular/core",
"@angular/forms": "@angular/forms",
"@angular/platform-browser": "@angular/platform-browser", "@angular/platform-browser-dynamic": "@angular/platform-browser-dynamic",
"@angular/router": "@angular/router",
rxjs: "rxjs",
"rxjs/operators": "rxjs/operators",
tslib: "tslib",
"single-spa": "single-spa",
};

Stop the running angular-application-two app and serve it again using the following command:

yarn serve:single-spa

Visit, http://localhost:9000/ once again by having the network tab opened.

You should notice the main.js file loaded from localhost:4300. Below would be your findings

Size of angular-application-two micro frontend after optimization

Note the difference in the size of the main.js file highlighted in blue. The file size before optimization (Webpack externals) was around 4MB which has now reduced to a mere 75KB.

Hurray, finally we have something to be proud of!

Glad to see you reached here. Appreciate your presence of mind and dedication 😃

Finally, Addressing the devil

There are caveats associated with every optimization and sharing dependencies is not an exception. Here are a few pointers that you should be aware of before you adopt shared dependencies in your architecture.

  • SystemJS loads shared dependencies as in-browser modules hence the tree shaking capabilities of Webpack are out of the picture. This would mean that in case a library is partially used by all the micro frontends, unused JS will still be loaded in the browser as in-browser modules do not provide tree shaking capabilities.
  • Adding libraries as inbrowser modules is similar to having a script tag for each dependency which makes a GET request to fetch the javascript located at a CDN location. Hence it is a decision to be taken with caution as to how many libraries will be shared and are loaded as in-browser modules vs build time modules.
  • In case an external library is used by less than 60% of the micro frontends in the ecosystem, prefer having them build time as we would end up loading the library as in-browser even when it is not required for the rest of the 40% modules. Especially when those 60% of the modules are accessed once or twice during the user journey.
  • Sharing dependencies can bite back in the long-run when a huge team is involved in the development of micro frontends as all the teams involved in the development should be aware of the particular version of an external dependency being used across all the micro frontends. As in our example, we shared the Angular framework with all the micro frontends but this could be an issue in case a team is starting up a new micro frontend and they want to adopt the more latest version of Angular which is where shared dependencies of Angular 8 will then turn out to be technical backlog rather than an optimization.

Sharing of dependencies amongst micro frontends should be a well thought out decision and should be addressed on a case by case basis rather than considering it as a silver bullet and sharing all the possible dependencies.

Spoiler Alert !!

Oh, did I forget to mention this, if you’re using Angular’s Ivy rendering engine in the micro frontend ecosystem, it is forbidden to share dependencies. Here’s a detailed explanation by Joel Denning.

--

--

Faizal Vasaya
Globant
Writer for

Technical Lead | Full-stack JS developer | Ethereum, Web 3.0, Solidity and Blockchain developer | GCP 1x