Docs
/
Angular
Chapter 15
15 — Performance Optimization
Performance Checklist
| Technique | Impact | Effort |
|---|---|---|
OnPush change detection | ★★★★★ | Low |
| Lazy loading routes | ★★★★★ | Low |
track in @for | ★★★★ | Low |
@defer for heavy components | ★★★★ | Low |
| Signals over methods in templates | ★★★★ | Medium |
| Virtual scrolling for long lists | ★★★★ | Medium |
| SSR + Hydration | ★★★★ | Medium |
| Tree shaking unused code | ★★★ | Low |
Image optimization (NgOptimizedImage) | ★★★ | Low |
| Bundle analysis + code splitting | ★★★ | Medium |
Lazy Loading Routes
// ✅ Each route loads its own JavaScript chunk
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.routes')
.then(m => m.ADMIN_ROUTES),
canActivate: [authGuard],
},
];
// Preload strategy — load lazy routes in background after initial load
provideRouter(routes, withPreloading(PreloadAllModules))
@defer — Lazy Load Template Sections
<!-- Load heavy chart only when visible -->
@defer (on viewport) {
<app-analytics-dashboard />
} @placeholder {
<div class="skeleton" style="height: 400px"></div>
}
<!-- Load after user interaction -->
@defer (on interaction) {
<app-comment-section [postId]="post.id" />
} @placeholder {
<button>Load Comments</button>
}
<!-- Load when browser is idle -->
@defer (on idle) {
<app-recommendations />
}
<!-- Prefetch when idle, render on viewport -->
@defer (on viewport; prefetch on idle) {
<app-footer />
}
Image Optimization — NgOptimizedImage
import { NgOptimizedImage } from '@angular/common';
@Component({
imports: [NgOptimizedImage],
template: `
<!-- LCP image — mark as priority -->
<img ngSrc="/assets/hero.jpg" width="1200" height="600" priority>
<!-- Regular image — lazy loaded by default -->
<img ngSrc="/assets/thumbnail.jpg" width="300" height="200">
<!-- With image loader (CDN) -->
<img ngSrc="hero.jpg" width="1200" height="600" priority>
`,
})
// app.config.ts — configure image loader for CDN
import { provideImgixLoader } from '@angular/common';
providers: [
provideImgixLoader('https://my-cdn.imgix.net/'),
]
NgOptimizedImage automatically:
- Sets
loading="lazy"(exceptpriorityimages) - Sets
fetchpriority="high"forpriorityimages - Generates
srcsetfor responsive images - Warns about missing
width/height(prevents layout shift)
Virtual Scrolling
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="72" class="list">
<app-user-card
*cdkVirtualFor="let user of users; trackBy: trackById"
[user]="user"
/>
</cdk-virtual-scroll-viewport>
`,
styles: [`.list { height: 80vh; }`],
})
export class UserListComponent {
users: User[] = []; // 10,000+ items — only visible ones are rendered
}
Bundle Analysis
# Build with stats
ng build --stats-json
# Analyze with webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/my-app/stats.json
# Or use source-map-explorer
npx source-map-explorer dist/my-app/browser/*.js
Common Bundle Issues
| Problem | Fix |
|---|---|
Large vendor.js | Lazy load features, remove unused imports |
moment.js in bundle | Replace with date-fns or dayjs |
Full lodash imported | Use lodash-es with tree shaking or individual imports |
| Icons loaded eagerly | Lazy load icon sets |
| Unused Material modules | Import only what you use |
Tree Shaking
// ❌ Imports entire library
import * as _ from 'lodash';
// ✅ Tree-shakable — only imports what's used
import { debounce } from 'lodash-es';
// ❌ Barrel file re-exports everything
export * from './components';
// ✅ Direct imports
import { ButtonComponent } from './components/button.component';
SSR + Hydration (Angular 17+)
ng add @angular/ssr
// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideClientHydration(withEventReplay()), // Replay events during hydration
],
};
Benefits:
- Faster FCP — HTML rendered on server
- SEO — Crawlers see full content
- Hydration — Angular reuses server-rendered DOM (no re-render flicker)
Runtime Performance Tips
Avoid Methods in Templates
<!-- ❌ Called on EVERY change detection cycle -->
<p>{{ getFullName() }}</p>
<div [class.active]="isActive()"></div>
<!-- ✅ Use computed signal -->
<p>{{ fullName() }}</p>
<!-- ✅ Or pure pipe -->
<p>{{ user | fullName }}</p>
Detach Rarely-Changing Components
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class StaticWidgetComponent {
private cdr = inject(ChangeDetectorRef);
ngAfterViewInit() {
this.cdr.detach(); // No more change detection
}
}
Use OnPush Everywhere
// angular.json — set OnPush as default for generated components
"schematics": {
"@schematics/angular:component": {
"changeDetection": "OnPush",
"standalone": true
}
}
Build Optimizations
// angular.json — production config
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB" }
],
"outputHashing": "all",
"optimization": true,
"sourceMap": false,
"extractLicenses": true
}
}
# Check bundle sizes
ng build --configuration=production
# Look at dist/ folder sizes
Key Takeaways
OnPush+ signals = the foundation of Angular performance- Lazy load everything: routes (
loadComponent), templates (@defer), images (NgOptimizedImage) - Use
cdk-virtual-scroll-viewportfor lists over ~100 items - Never call functions in templates — use
computed()or pure pipes - Run
source-map-explorerto find bundle bloat - SSR + hydration gives faster first paint and SEO benefits
- Set bundle budgets in
angular.jsonto catch size regressions in CI