Angular 96 ๐ ฐ๏ธ Performance โ Bundles and Loading
The previous chapter covered change detection and rendering โ the work Angular does while the application is running. This chapter covers the work that happens before the application runs: downloading, parsing, and executing the JavaScript, CSS, and assets that make up the application. A fast application is not just one that renders efficiently. It is one that starts quickly. The bundle size and loading strategy determine how long the user waits before the first meaningful content appears.
The Angular CLI uses esbuild as its build engine. The default build produces a set of bundles: the main bundle, the polyfills bundle, the styles bundle, and a set of lazy-loaded chunks. The size of these bundles determines the download time. The loading strategy determines when each bundle is fetched. The two together determine the user’s perceived performance.
Key point: Angular’s build is optimized by default, but the defaults are not enough for every application. The initial bundle includes the application’s root code and everything imported by it. A large initial bundle means a slow first paint. The techniques in this chapter โ lazy loading, @defer, tree-shaking, bundle budgets, and preloading โ reduce the initial bundle and defer the rest until it is needed.
Why bundle size matters
A JavaScript bundle is not free. The browser must download it, parse it, compile it, and execute it. Each step takes time. On a fast desktop with a fast connection, the difference between a 200 KB bundle and a 2 MB bundle is measured in milliseconds. On a mid-range phone with a slow connection, the difference is measured in seconds. The user sees a blank screen until the bundle is loaded and the application is bootstrapped.
The download problem. A 1 MB bundle over a 3G connection takes roughly 8 seconds to download. Over a 4G connection, roughly 2 seconds. Over a fast Wi-Fi connection, roughly 200 milliseconds. The network is the bottleneck for most users. Reducing the bundle size is the most direct way to reduce the initial load time.
The parse problem. The browser must parse the JavaScript before it can execute it. Parsing is fast for small bundles and slow for large ones. The V8 engine can parse roughly 1 MB of JavaScript per second. A 3 MB bundle takes roughly 3 seconds to parse, even after it is downloaded.
The execution problem. The top-level code in the bundle runs during bootstrap. A large Angular application with many components registered at startup executes more code before the first render. The @defer block and lazy loading reduce the amount of code that must execute before the first paint.
The cache problem. A bundle that changes on every deployment is re-downloaded on every visit. A bundle that is cached by the browser is loaded from disk. The Angular CLI uses content hashing: the filename includes a hash of the contents. When the contents change, the hash changes, and the browser downloads the new version. When the contents are unchanged, the hash is the same, and the browser uses the cached version.
The trade-off. Smaller bundles mean more chunks and more requests. A single 1 MB bundle is faster to download than ten 100 KB bundles over HTTP/1.1 because of the per-request overhead. But HTTP/2 and HTTP/3 multiplex the requests, and the per-request overhead is minimal. The modern approach is to split the application into many small chunks and let the bundler and the browser handle the loading. The initial chunk should be as small as possible.
a. The Default Bundle Structure
When you run ng build, the Angular CLI produces a set of files in the dist/ directory. The main files are:
main.js is the application bundle. It contains the root component, the bootstrapping code, and every module imported by the root component. It is the largest bundle in a typical application.
polyfills.js contains the polyfills for the JavaScript features that the application uses but the target browsers do not support. Angular no longer includes Zone.js by default in zoneless applications. In traditional applications, Zone.js is in the polyfills bundle.
styles.css contains the global styles. Component styles are included in the JavaScript bundles and injected at runtime.
vendor.js contains the third-party libraries. In the modern esbuild-based build, the vendor code is merged into the main bundle by default. The vendorChunk option splits it out.
Lazy chunks are created for routes and components loaded with loadChildren or loadComponent. Each lazy chunk is a separate file that is downloaded only when the route is activated.
The build output includes a summary of the sizes. The Initial total is the size of the bundles that are loaded on the first visit. The Lazy chunks are the sizes of the bundles that are loaded on demand.
Initial total | 450.23 kB
Lazy | 80.12 kB
The initial total is the number that matters most. It is the size of the code the user must download before the application is usable.
b. Lazy Loading Routes
The most impactful optimization for a large application is lazy loading. A route that is lazy-loaded is not included in the initial bundle. It is downloaded only when the user navigates to the route.
The lazy loading is configured with loadComponent or loadChildren:
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];
The import() function returns a promise that resolves to the module. The bundler sees the dynamic import and creates a separate chunk for the module. The chunk is downloaded when the route is activated.
The effect on the initial bundle is significant. If the application has a home page and a dashboard, and the dashboard is lazy-loaded, the dashboard’s code is not in the initial bundle. The user downloads the home page first. When they navigate to the dashboard, the dashboard chunk is downloaded.
The lazy loading can be combined with preloading. The preloading strategy loads the lazy chunks in the background after the initial application is loaded. The user does not wait for the chunk when they navigate to the route because the chunk is already in the browser’s cache.
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};
The PreloadAllModules strategy preloads all lazy routes. For a custom strategy, the PreloadingStrategy interface can be implemented to preload only specific routes .
c. Deferring Components with @defer
The @defer block is the component-level equivalent of lazy loading. It defers the loading of a component and its dependencies until a trigger fires.
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-placeholder">Chart loads when visible</div>
} @loading (minimum 500ms) {
<div class="spinner">Loading chart...</div>
} @error {
<div class="error">Failed to load chart.</div>
}
The @defer block is compiled into a dynamic import. The component and its dependencies are placed in a separate chunk. The chunk is downloaded when the trigger fires. The @placeholder is shown before the trigger fires. The @loading is shown while the chunk is downloading. The @error is shown if the download fails.
The triggers are idle, viewport, interaction, hover, immediate, timer, and when. The viewport trigger uses the Intersection Observer API to detect when the placeholder enters the viewport. The interaction trigger detects a click or key press. The hover trigger detects a mouse over. The timer trigger fires after a specified duration .
The @defer block is the most precise tool for reducing the initial bundle. A component that is below the fold, behind a tab, or behind an interaction can be deferred. Its code is not part of the initial bundle. It is downloaded only when the user needs it.
Complete Example Session
This session demonstrates the bundle optimization techniques on a small Angular application.
// ============================================
// PART 1: THE ROUTES WITHOUT LAZY LOADING
// ============================================
// Before optimization:
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { AdminComponent } from './admin/admin.component';
import { SettingsComponent } from './settings/settings.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'admin', component: AdminComponent },
{ path: 'settings', component: SettingsComponent },
];
// All components are imported statically.
// All components are in the initial bundle.
// The initial bundle is large.
// ============================================
// PART 2: THE ROUTES WITH LAZY LOADING
// ============================================
// After optimization:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
},
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
{
path: 'settings',
loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent),
},
];
// The components are loaded dynamically.
// Each component is in a separate chunk.
// The initial bundle contains only the shell.
// ============================================
// PART 3: THE BUILD OUTPUT
// ============================================
// ng build --configuration production
// Before lazy loading:
// Initial total | 1.45 MB
// Lazy | 0 B
// After lazy loading:
// Initial total | 380 kB
// Lazy | 420 kB
// - dashboard | 120 kB
// - admin | 180 kB
// - settings | 120 kB
// The initial bundle is 73% smaller.
// The lazy chunks are loaded on demand.
// ============================================
// PART 4: THE PRELOADING STRATEGY
// ============================================
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};
// The preloading strategy loads the lazy chunks
// in the background after the initial load.
// The user does not wait when navigating.
// ============================================
// PART 5: THE DEFER BLOCK
// ============================================
// dashboard.component.html
@Component({
selector: 'app-dashboard',
template: `
<h1>Dashboard</h1>
<app-summary [data]="summary()" />
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-placeholder">Chart loads when visible</div>
} @loading (minimum 500ms) {
<div class="spinner">Loading chart...</div>
}
`,
})
export class DashboardComponent {
summary = signal<Summary | null>(null);
chartData = signal<ChartData | null>(null);
}
// The heavy chart is in a separate chunk.
// The chunk is downloaded when the chart enters the viewport.
// ============================================
// PART 6: THE TREE-SHAKING
// ============================================
// Tree-shaking removes unused code from the bundle.
// The build is tree-shaken by default.
// โ This import includes the entire library:
import * as _ from 'lodash';
// โ
This import includes only the function:
import { debounce } from 'lodash-es';
// Use named imports instead of namespace imports.
// Use the ESM build of the library.
// ============================================
// PART 7: THE BUNDLE BUDGETS
// ============================================
// angular.json
{
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
},
{
"type": "any",
"maximumWarning": "100kB",
"maximumError": "200kB"
}
]
}
// The budgets set limits on the bundle sizes.
// The build fails if a bundle exceeds the maximumError.
// The build warns if a bundle exceeds the maximumWarning.
// ============================================
// PART 8: THE BUILD ANALYZER
// ============================================
// Install the analyzer:
// npm install --save-dev esbuild-visualizer
// Build with the stats file:
// ng build --stats-json
// Analyze the bundle:
// npx esbuild-visualizer --metadata dist/app/stats.json
// The analyzer shows:
// - Which modules are in the bundle
// - The size of each module
// - The size of each chunk
// - The dependencies between chunks
// ============================================
// PART 9: THE OPTIMIZATION CHECKLIST
// ============================================
// 1. Lazy load all routes that are not on the initial page
// 2. Use @defer for components below the fold
// 3. Use preloading for routes the user is likely to visit
// 4. Use named imports instead of namespace imports
// 5. Use the ESM build of libraries
// 6. Set bundle budgets to prevent regressions
// 7. Analyze the bundle to find large modules
// 8. Remove unused dependencies
// 9. Use the production build for deployment
// 10. Monitor the bundle size in CI
// ============================================
// PART 10: THE RESULTS
// ============================================
// Before optimization:
// Initial total | 1.45 MB
// Time to interactive (4G) | 3.2s
// After optimization:
// Initial total | 380 kB
// Time to interactive (4G) | 1.1s
// The initial bundle is 73% smaller.
// The time to interactive is 66% faster.
// The lazy chunks are loaded on demand.
// The heavy components are deferred.
// The bundle budgets prevent regressions.
The ten parts cover the routes without lazy loading, the routes with lazy loading, the build output, the preloading strategy, the @defer block, the tree-shaking, the bundle budgets, the build analyzer, the optimization checklist, and the results.
Quick Reference
The Bundle Files
| File | Contents |
|---|---|
main.js | Application code |
polyfills.js | Polyfills (Zone.js in traditional apps) |
styles.css | Global styles |
vendor.js | Third-party libraries (if split) |
| Lazy chunks | Route and component chunks |
The Lazy Loading Syntax
| Syntax | Purpose |
|---|---|
loadComponent: () => import(...) | Lazy load a component |
loadChildren: () => import(...) | Lazy load routes |
withPreloading(PreloadAllModules) | Preload all lazy chunks |
Custom PreloadingStrategy | Selective preloading |
The @defer Triggers
| Trigger | Fires When |
|---|---|
idle | Browser is idle |
viewport | Element enters viewport |
interaction | Click or key press |
hover | Mouse hovers |
immediate | After non-deferred content |
timer | After specified duration |
when | Custom condition is true |
The Bundle Budgets
| Type | Purpose |
|---|---|
initial | Initial bundle size |
anyComponentStyle | Component style size |
any | Any bundle size |
bundle | Specific bundle size |
The Optimization Techniques
| Technique | Impact |
|---|---|
| Lazy loading | Large |
@defer | Large |
| Tree-shaking | Medium |
| Preloading | Perceived speed |
| Bundle budgets | Prevent regression |
Best Practices
โ Do This:
// Lazy load all routes
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) // โ
<!-- Defer below-the-fold components -->
@defer (on viewport) { <heavy-chart /> } <!-- โ
-->
// Use named imports
import { debounce } from 'lodash-es'; // โ
// Set bundle budgets
{ "type": "initial", "maximumWarning": "500kB" } // โ
// Preload lazy chunks
provideRouter(routes, withPreloading(PreloadAllModules)) // โ
โ Don’t Do This:
// Don't statically import all components
import { DashboardComponent } from './dashboard.component'; // โ
// Don't use namespace imports
import * as _ from 'lodash'; // โ
<!-- Don't load everything eagerly -->
<app-heavy-chart /> <!-- in the initial template --> <!-- โ -->
// Don't ignore bundle budgets
"budgets": [] // no limits // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Initial bundle too large | No lazy loading | Lazy load routes |
| Bundle size growing | No budgets | Set budgets in angular.json |
| Unused code in bundle | Namespace imports | Use named imports |
| Deferred component not loading | Wrong trigger | Check the @defer trigger |
| Lazy route not loading | Wrong import path | Check the path |
| Slow first paint | Large polyfills | Audit polyfills |
Real-World Examples
1. Lazy Load a Route
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent)
2. Lazy Load Children
loadChildren: () => import('./admin.routes').then(m => m.ADMIN_ROUTES)
3. Preload All Modules
provideRouter(routes, withPreloading(PreloadAllModules))
4. Defer on Viewport
@defer (on viewport) { <heavy-chart /> }
5. Defer on Interaction
@defer (on interaction) { <comments /> }
6. Named Import
import { debounce } from 'lodash-es';
7. Bundle Budget
{ "type": "initial", "maximumWarning": "500kB" }
8. Build with Stats
ng build --stats-json
9. Analyze Bundle
npx esbuild-visualizer --metadata dist/app/stats.json
10. Production Build
ng build --configuration production
Visual
The Bundle Structure
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ INITIAL BUNDLE โ
โ main.js โ application code โ
โ polyfills.js โ polyfills โ
โ styles.css โ global styles โ
โ โ
โ LAZY CHUNKS โ
โ dashboard.js โ loaded on navigation โ
โ admin.js โ loaded on navigation โ
โ settings.js โ loaded on navigation โ
โ โ
โ DEFERRED CHUNKS โ
โ chart.js โ loaded on viewport โ
โ comments.js โ loaded on interaction โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Lazy Loading Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ LAZY LOADING โ
โ โ
โ Initial load: โ
โ main.js + polyfills.js + styles.css โ
โ โโ Application shell renders โ
โ โ
โ User navigates to /dashboard: โ
โ dashboard.js downloads โ
โ โโ Dashboard component renders โ
โ โ
โ User navigates to /admin: โ
โ admin.js downloads โ
โ โโ Admin component renders โ
โ โ
โ The initial load is smaller. โ
โ The chunks are loaded on demand. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The @defer Lifecycle
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ @defer LIFECYCLE โ
โ โ
โ 1. Initial render โ
โ โโ @placeholder shown โ
โ โ
โ 2. Trigger fires โ
โ โโ Chunk download begins โ
โ โ
โ 3. @loading shown โ
โ โโ While chunk downloads โ
โ โ
โ 4. Chunk downloaded โ
โ โโ Component renders โ
โ โ
โ 5. Error (if download fails) โ
โ โโ @error shown โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Bundle Budget
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ BUNDLE BUDGETS โ
โ โ
โ initial: โ
โ maximumWarning: 500kB โ
โ maximumError: 1MB โ
โ โ
โ anyComponentStyle: โ
โ maximumWarning: 4kB โ
โ maximumError: 8kB โ
โ โ
โ any: โ
โ maximumWarning: 100kB โ
โ maximumError: 200kB โ
โ โ
โ The build fails if a budget is exceeded. โ
โ The build warns if a warning is exceeded. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Initial bundle | main.js, polyfills.js, styles.css |
| Lazy chunks | Loaded on navigation |
| Deferred chunks | Loaded on trigger |
| Lazy loading | loadComponent, loadChildren |
| Preloading | withPreloading(PreloadAllModules) |
| Defer triggers | idle, viewport, interaction, hover, timer, when |
| Tree-shaking | Named imports, ESM builds |
| Bundle budgets | initial, anyComponentStyle, any |
| Build analyzer | esbuild-visualizer |
| Production build | ng build --configuration production |
Key takeaways:
- The initial bundle is the code the user must download before the application is usable. Reducing its size is the most direct way to reduce the initial load time. The
main.js,polyfills.js, andstyles.cssfiles make up the initial bundle . - Lazy loading routes is the most impactful optimization. A route loaded with
loadComponentorloadChildrenis not in the initial bundle. It is downloaded only when the route is activated. The initial bundle can shrink by 70% or more . @deferdefers components and their dependencies. The component is placed in a separate chunk and downloaded only when a trigger fires. Theviewporttrigger is the most common for below-the-fold content. Theinteractiontrigger is used for user-initiated content .- Preloading loads lazy chunks in the background. The
PreloadAllModulesstrategy preloads all lazy routes after the initial load. The user does not wait when navigating because the chunk is already cached. A customPreloadingStrategycan preload only specific routes . - Tree-shaking removes unused code. The build is tree-shaken by default, but the tree-shaking is only effective if the imports are structured correctly. Named imports are tree-shakeable. Namespace imports are not .
- Bundle budgets prevent regressions. The
angular.jsonfile can set size limits for the initial bundle, the component styles, and any bundle. The build fails if a budget is exceeded. The budgets are a guard against accidental bundle growth . - The build analyzer shows what is in the bundle. The
--stats-jsonflag produces a stats file. Theesbuild-visualizertool renders the stats as a treemap. The analyzer identifies the largest modules and the opportunities for optimization.
Remember: Performance is not just about what happens after the application loads. It is about how long the user waits before the application loads. The bundle size and the loading strategy determine that wait. Lazy loading reduces the initial bundle. @defer defers what is not needed. Preloading hides the latency. Tree-shaking removes the unused. Bundle budgets prevent the regression. The build analyzer shows what is in the bundle. The production build optimizes what remains.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!