In the fast-paced world of web development, Angular applications often face the challenge of slow loading times, mainly due to the inclusion of non-treeshakable libraries. Developers, in pursuit of functionality, sometimes overlook the impact of these decisions on application performance, leading to a negative user experience. This post unveils an efficient method for analyzing application bundles, enabling developers to pinpoint and minimize the footprint of large libraries. By making informed coding decisions, developers can significantly enhance application performance and user satisfaction.
Introducing Esbuild: A Speed Revolution for Angular
Esbuild, a remarkably fast JavaScript bundler and minifier, has transformed the build process with its speed, compiling JavaScript into highly efficient machine code. With Angular v17, Angular officially adopted Esbuild as its default bundler, marking a significant shift from Webpack v5. For developers migrating from previous versions, activating Esbuild involves updating the angular.json file to use the new bundler configuration.
Esbuild is a fast JavaScript bundler and minifier known for its exceptional speed. Written in Go, it compiles JavaScript into highly efficient machine code. With Angular v17, Angular officially adopted Esbuild as its default bundler, marking a significant shift from Webpack v5. If you're transitioning from an existing Angular application, you'll need to manually enable the new bundler in your angular.json
file by replacing the Webpack-based bundler with the appropriate configuration.
// webpack configuration before Angular v17
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
...
// esbuild configuration since Angular v17
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
...
// esbuild fallback as dropin replacement, not supporting hot reload + SSR
...
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser-esbuild",
...
A Closer Look at Our Demo Application
I have created a small demo application to illustrate the usefulness of analyzing the produced application bundle. This application contains two Angular components, each importing a third-party library. The libraries are lightweight-charts from TradingView and moment.js, a widely used library in many production applications. Both have a significant footprint in terms of bundle size. I'll demonstrate two different approaches to optimize the bundle size. You can find the repository on my GitHub profile: ng-esbuild-analyze.
export const routes: Routes = [
{
path: '',
redirectTo: 'charts',
pathMatch: 'full',
},
{
path: 'charts',
component: ChartComponentComponent,
},
{
path: 'moment',
component: MomentComponentComponent,
},
];
The chart component is rendering a basic line chart.
import { Component, ElementRef, effect, viewChild } from '@angular/core';
import { createChart } from 'lightweight-charts';
@Component({
selector: 'app-chart-component',
standalone: true,
template: '<div #chart></div>',
})
export class ChartComponentComponent {
chart = viewChild.required('chart', { read: ElementRef });
renderChart = effect(() => {
const chart = createChart(this.chart().nativeElement, {
width: 400,
height: 300,
});
const lineSeries = chart.addLineSeries();
lineSeries.setData([
{ time: '2019-04-11', value: 80.01 },
{ time: '2019-04-12', value: 96.63 },
{ time: '2019-04-13', value: 76.64 },
{ time: '2019-04-14', value: 81.89 },
{ time: '2019-04-15', value: 74.43 },
]);
});
}
The moment component parses a string as a Date and formats it when the user clicks on a button.
import { Component, signal } from '@angular/core';
import moment from 'moment';
@Component({
selector: 'app-moment-component',
standalone: true,
template: `
<p>{{ myBirthday() }}</p>
<button (click)="setBirthday()">Set Birthday</button>
`,
})
export class MomentComponentComponent {
public myBirthday = signal<string | null>(null);
setBirthday() {
this.myBirthday.set(moment('1995-05-11').format('YYYY-MM-DD'));
}
}
As you can see, the code isn't particularly complex. It simply involves importing two libraries and executing some of their functions. Now, let's examine the bundle produced by esbuild.
Building and Analyzing Your Bundle
We need to instruct the Angular builder to create a file called stats.json
. We can analyze this file later. To generate the file, simply add one flag to the build command.
ng build --stats-json
After executing the build, check the dist folder for the stats.json
file.
Once the required file is generated, it's time to analyze it using the esbuild bundle size analyzer. You can also add an npm dev dependency esbuild-visualizer and execute it with a defined npm script.
npm i -D esbuild-visualizer
npx esbuild-visualizer --metadata ./dist/ng-esbuild-analyze/stats.json
However, for this blog post, I will use the web tool.
Simply click the Import your metafile...
button and select the stats.json
file to view the results.
As you can see, esbuild created two separate JavaScript files for us. The main bundle contains all of our application's code, and a polyfills file ensures better browser compatibility. However, this will result in our application loading slowly.
Optimizing Bundle Size for Better Loading Performance
The first step we can take, which is fundamental for all Angular applications, is to enable lazy loading for our routes. This will create a separate bundle for each route.
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
redirectTo: 'charts',
pathMatch: 'full',
},
{
path: 'charts',
loadComponent: () =>
import('./chart-component/chart-component.component').then(
(m) => m.ChartComponentComponent
),
},
{
path: 'moment',
loadComponent: () =>
import('./moment-component/moment-component.component').then(
(m) => m.MomentComponentComponent
),
},
];
Let's run the build again and compare the results.
As you can see, we now have five bundles instead of two. This addresses some, but not all, of our issues. The chart component is satisfactory because we render the chart directly when the component is rendered. However, by considering our moment component, we could further optimize the bundle.
setBirthday() {
// moment is only used when the user clicks on a button
this.myBirthday.set(moment('1995-05-11').format('YYYY-MM-DD'));
}
Looking back at the router configuration of our application, you'll see that it references our components lazily using the JavaScript import
statement. This same syntax can be used to lazy load any TypeScript/JavaScript based code, such as our moment.js library.
setBirthday() {
// this will create us a separate bundle for moment
import('moment').then((moment) => {
this.myBirthday.set(moment.default('1995-05-11').format('YYYY-MM-DD'));
});
}
When you run the build again, you should see another bundle produced by esbuild. Now, moment will be loaded on demand when the user clicks on the button, instead of loading directly with the component itself. You can also check out the network tab of the browser dev-tools to check the result.
We can also observe that moment.js contributes significantly to our application's footprint because it is not tree-shakable. Despite only using two functionalities of the library, we must import the entire library into our application. Without the bundle analyzer, we might not have identified this issue and considered replacing the library with a more lightweight solution such as date-fns or the Intl.DateTimeFormat API. Before importing any npm-package into your code base, you can analyze it using web apps like Bundlephobia.
Using deferrable views
Deferrable views, also known as @defer blocks, are a powerful tool introduced in Angular v17. They can be used to reduce the initial bundle size of your application or defer heavy components that may not ever be loaded until a later time.
You could also utilize the deferrable views feature to lazy load the chart that was described earlier in this post, which is rendered inside the Chart Component. By refactoring the chart into a standalone component and loading it lazily, the initial routing to the component can be expedited. This approach can further optimize the performance and user experience of your application.
If you want to learn more about deferrable views, I recommend reading through the blog post by Alexander Thalhammer, an Angular trainer and consultant at Angular Architect. He offers a detailed explanation of the API and its benefits for bundle size optimization.
Conclusion and further solutions
As we've seen throughout this article, the analysis of our application bundle generated with esbuild provides valuable insights into how to optimize our application for better performance. We've been able to use the import
statement to lazy load third-party libraries on demand, significantly reducing the initial load time of our application.
Before adding any npm packages to your project, I recommend using tools like Bundlephobia to analyze the impact of these packages on your bundle size. This tool can help you make informed decisions on whether to include a package in your project, based on its size and performance impact.
Furthermore, consider replacing legacy and non-treeshakable libraries with modern alternatives. This can drastically reduce your bundle size and improve the overall performance of your application. However, if you still have to use these libraries, you can use the import
statement to lazy load them on demand as a fallback solution. This allows your application to only load these libraries when they are actually required, reducing the initial load time of your application.
In conclusion, a thoughtful and proactive approach to analyzing and optimizing your application bundle can result in significant performance improvements. This not only enhances the user experience but also contributes to the overall success of your application.
Top comments (0)