Improve SPA performance by splitting your Angular libraries in multiple chunks

Take advantage of ng-packagr's secondary entry points

Kevin Kreuzer
Nov 26 · 10 min read

Angular is an awesome framework. We all love it 😍

One thing that makes Angular successful and excellent at the same time is the vast community and the value it provides. There are a lot of Angular meetups, blogs, conferences, and of course, libraries.

Nowadays, thanks to the Angular CLI, libraries are easy to create. They are a great way to share code across multiple applications.

Since they can be used in many places, performance is a critical aspect. A library that doesn’t perform can slow down multiple applications!

In Frontend, there are different types of performance. Runtime performance and initial load. In this article, we will focus on initial load.

While providing and maintaining various libraries and a UI framework for a big enterprise, I encountered some not so apparent pitfalls and ways to deal with them. I think it’s worth sharing some of those experiences.

💬 “It’s such a simple library. It can’t affect performance, right?”

Let’s start with a simple library that we generate with the help of the Angular CLI. In case you have never created an Angular library, it might be helpful to read the following article:

Once we used the CLI to set up the multi-project workspace, we can start to add some code.

The library is called howdy and it’s only purpose is to greet you with your name or to tell you the local time. It contains two modules with each a component. One module to greet and one to tell the time.

HowdyNameModule and HowdyNameComponent

Just a simple Angular module and a Component that accepts a name property over Input bindings and displays it.

HowdyTimeModule and HowdyTimeComponent

The HowdyTimeComponent is responsible for displaying the time with the help of the third-party library moment.

Great! Our howdy library is ready to be published! It’s such a simple library; it can’t affect performance, right?

Consume the howdy library 🤠

The howdy library is out! It would be a shame not to take advantage of it. 😉 To use the howdy library, we generate a brand new SPA with the Angular CLI.

ng new greeting-app

Since we are interested in performance, let’s also install a dev dependency called webpack-bundle-analyzer.

npm i -D webpack-bundle-analyzer

The webpack-bundle-analyzer allows you to visualize size of webpack output files with an interactive zoomable treemap.

The best way to analyze our bundle is to add the following analyze script to our package.json.

"analyze": "ng build --prod --stats-json && webpack-bundle-analyzer ./dist/greeting-app/stats-es2015.json"

If we run this command, Angular performs a production build and also outputs a stats-es2015.json which is then picked up and visualized by webpack-bundle-anlyzer.

Bundle size of a brand new Angular SPA

Since we haven’t written any code yet, our main bundle mainly consists of Angular. We can also see that zone.js is included in our polyfill bundle.

Altogether, the size of our application is now at 207 KB.

But we haven’t yet included our howdy library! Let’s go ahead and do so.

npm i howdy

We installed the howdy library because we want to display a greeting with a name. We are not interested in presenting the time. Therefore we are only going to use the HowdyNameModule and we do not include the HowdyTimeModule.

The vital thing to notice here is that we only import the HowdyNameModule. Let’s rerun the analyze script.

Wow! Pretty boiled! We went from 207 KB to 511.15 KB. More than double the size. What the f…! 😳

One look is enough to find the culprit. moment is huge! It brings its core implementation and all the locales.

For sure, moment can be replaced with other packages like date-fns or moment-mini. But the real question is; Why is it even there? Remember that we only imported the HowdyNameModule and not the HodwyTimeModule. I thought tree shaking shakes away unused modules? 🤔 What’s going on?

Not everything can be tree shaked 🎄

To make tree shaking possible, the Angular build runs a bunch of advanced optimization. But still moment does occur in the bundle even if the HowdyTimeModule doesn’t.

The problem lies in the way moment is packaged. Let’s have a quick look at moment.js file in our node_modules folder.

moment.js — bundled in UMD format

Since moment can be used in multiple places like Node JS backends, Angular applications or plain JavaScript it is bundled in UMD and not as ES module.

UMD bundled libraries are wrapped in an IFFE, which means that ModuleConcatenation can't be used. There’s no way for the build optimization tools to know if this code will be used or has side effects.

In a nutshell, this type of module prevents Angular from running the more advanced bundle optimizations.

Unfortunately, we can’t control how moment is packaged. Does this mean we need to accept this huge bundle size?

Secondary entry points for the win 🤩

We can’t control how moment is built. But we can manage our library. And indeed, there’s a way to prevent such scenarios. Secondary entry points!

Nowadays, almost all Angular libraries are packaged with ng-packagr. ng-packagr allows you to use an ng-package.json in combination with a public-api which will end up as an entry point to your application.

As the name indicates, secondary entry points allow you to specify multiple entry points to your application.

Sounds good! 👍 How to enable secondary entry points?

Secondary entry points are dynamically discovered by ng-packagr. ng-packagr searches for package.json files within subdirectories of the main package.json file's folder.

Cool! Let’s take advantage of secondary entry points in our howdy library by adding the following files.

The first step to enable secondary entry points — add index.ts, package.json, and public_api.ts

For each module, we added a index.ts, a package.json and a public_api.ts.

  • The index.ts is just there to point to the public_api which is helpful during imports.
  • The public_api exports all the modules and components from our module.
  • The package.json contains ng-packagr specific configurations. In our example, it’s enough to specify the entryFile.

The package.json can also contain other properties like cssUrl etc… Notice that the scope of those properties is just the current subentry.

If we now run a build, we would end up with three chunks. howdy.js, howdy-src-lib-name.js and howdy-src-lib-time.js.

The howdy-src-lib-name.js now only contains the HowdyNameModule related code, and the howdy-src-lib-time.js now only contains the HowdyTimeModule specific code.

But let’s have a look at the howdy.js chunk.

howdy.js still includes HowdyNameComponent and HowdyTimeComponent

The howdy.js chunk still contains HowdyNameComponent and HowdyTimeComponent. Means, we still get moment even if we only import the HowdyNameModule.

If we want to shake away HowdyTimeModule with the current approach, we would need to use deep imports. So that we don’t import from howdy.js but directly from howdy-src-lib-time.js

Which is not recommended! Deep imports are dangerous and should always be avoided!

How can we address those issues? How can we ensure that the HowdyTimeModule also get shaken away even if we use standard imports? Well, we need to adjust the way the howdy.js chunk is created.

Use a “signpost”

The idea is removing the code from the howdy.js chunk and instead let it act as a sort of “signpost” which points you to the other chunks.

Let’s, therefore, have a closer look at src/public_api.ts.

/*
* Public API Surface of howdy
*/
export * from './lib/name/howdy-name.component';
export * from './lib/name/howdy-name.module';
export * from './lib/time/howdy-time.component';
export * from './lib/time/howdy-time.module';

Those lines are responsible for including everything from name and time in the howdy.js chunk. We need to remove the code fromhowdy.js and let it point to the other fragments which contain the implementation. Let’s adjust its content.

/
* Public API Surface of howdy
*/
export * from 'howdy/src/lib/name';
export * from 'howdy/src/lib/time';

Instead of exporting the real implementation, we put a relative path to the different chunks. With this change the howdy.js only points to other bundles and does not include any “real” code.

Let’s run ng build and analyze our dist folder.

Howdy library with multiple chunks

The howdy.js now acts as “signpost” which points to the chunks that contain the implementation. The howdy-src-lib-name.js chunk only contains the code from the name folder and the howdy-src-lib-time.js only contains the code from the time folder.

Consum a package with Subentries

Let’s update the howdy package in our greeting-app and rerun the analyze script.

Cool. The size of the bundle is now at 170.94 KB. A little bit higher than initially. Let’s check out how the howdy module looks like in the final bundle.

Great! This adjustment allows us to keep the bundle size of the consuming SPA small. The SPA only gets what they need!

Secondary entry points are very cool when you use them in combination with lazy loading. If we would use the HowdyTimeModule in a lazy loaded module, moment would end up in a lazy loaded chunk and not in the main chunk.

Real-world experiences 👷

The example above is very straight forward.

However, it is different once you introduce secondary entry points in an existing enterprise project. You have to deal with a lot more complexity while the error messages from ng-packagr are not always helpful.

Chances are high that you need to adjust some import paths or specify some path mappings in your tsconfig.json. And you will also be confronted with modules from one chunk which use modules from another chunk.

But trust me, once you master those burdens, it will be worth it.

In a vast enterprise environment, we figured out that the bundle size of freshly created SPAs exploded after we included some of the libraries which we provided.

At some point, it even went up to 5MB. 😮 Every SPA got moment, @swimlane/datatable and other things it did not even use. We started to focus on optimizing this bundle size.

We removed moment with date-fns and started to use secondary entry points. We currently ended up with a main chunk of 662 KB for a freshly created SPA that includes some libraries. It’s still big, but, we are not yet there. The optimization is not yet finished — we can reduce the bundle size even more.

It’s quite cool to see where we are now and where we came from. 💪

However, while it’s quite easy to take advantage of secondary entry points in the example above, it can be quite cumbersome to introduce them to a more significant project.

Conclusion

Angular does a remarkable job when it comes to optimizing bundle size. Even though those build optimization steps are very sophisticated, they can not tree shake everything.

Modules packaged in other formats than ESModules can not be tree shaked.

As library creators, we should, therefore, carefully watch the effects of our library on bundle size when we include third-party libraries.

We can’t control how third-party libs are packaged. But we are very well in control of how our library is packaged.

Subentries offer us an excellent way to deliver our library in multiple chunks. Those chunks are tree shakeable during Angulars build optimization. With this approach, even wrongly packaged third party libraries only get included in the final bundle if they are used.

🧞‍ 🙏 If you liked this post, share it and give some claps👏🏻 by clicking multiple times on the clap button on the left side.

Claps help other people to discover content and motivate me to write more 😉

Feel free to check out some of my other articles about front-end development or download one of my open-source modules!

Ng-sortgrid

Articles

Angular In Depth

The place where advanced Angular concepts are explained

Kevin Kreuzer

Written by

Passionate freelance frontend engineer. ❤️ Always eager to learn, share and expand knowledge.

Angular In Depth

The place where advanced Angular concepts are explained