Improve SPA performance by splitting your Angular libraries in multiple chunks
Take advantage of ng-packagr's secondary entry points

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.

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

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
.

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.

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.

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 thepublic_api
which is helpful during imports. - The
public_api
exports all the modules and components from our module. - The
package.json
containsng-packagr
specific configurations. In our example, it’s enough to specify theentryFile
.
The
package.json
can also contain other properties likecssUrl
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.

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 fromhowdy.js
but directly fromhowdy-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.

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
