Angular’s production build pipeline is powerful — but only if you understand how to use it. Tree-shaking removes unused code, lazy loading splits bundles by route, and image optimisation reduces the largest contentful paint. This lesson covers the full performance optimisation toolbox: analysing bundles with source-map-explorer, applying code splitting, using Angular’s @defer blocks, the NgOptimizedImage directive, and CDK virtual scrolling — giving you the tools to hit Core Web Vitals targets that matter for search ranking and user experience.
Core Web Vitals for Angular SPAs
| Metric | Target | What Angular Controls |
|---|---|---|
| Largest Contentful Paint (LCP) | < 2.5s | Bundle size, lazy loading, image optimisation, SSR/prerendering |
| Cumulative Layout Shift (CLS) | < 0.1 | Image dimensions, font loading, content placeholders |
| Interaction to Next Paint (INP) | < 200ms | Change detection strategy, OnPush, heavy computation deferral |
| First Contentful Paint (FCP) | < 1.8s | Initial bundle size, critical CSS, preconnect hints |
Bundle Optimisation Techniques
| Technique | Typical Saving | How |
|---|---|---|
| Lazy route loading | 50–80% initial bundle reduction | loadComponent / loadChildren in routes |
@defer blocks |
Defer non-critical UI below the fold | Angular 17 built-in deferrable views |
| Angular production build | 50–70% vs development | ng build --configuration production |
| NgOptimizedImage | 30–70% image size reduction | <img ngSrc="..."> directive |
| Tree-shaking imports | 10–30% per library | Import named exports, not entire libraries |
| Virtual scrolling | Enables 10,000+ item lists | CDK cdk-virtual-scroll-viewport |
@defer block — a built-in way to lazily load Angular components and their dependencies based on a trigger (idle, viewport, timer, interaction). Unlike lazy-loaded routes (which split at the route boundary), @defer splits at the component level within a page — ideal for below-the-fold content, heavy widgets, and non-critical UI elements like comment sections or analytics charts.ng build --stats-json then open the output with npx webpack-bundle-analyzer dist/app/browser/stats.json to visualise what is in your bundle. Common culprits: importing all of Lodash instead of individual functions, all of RxJS operators, large icon libraries, and moment.js. Always import specific named exports from specific module paths for best tree-shaking.index.ts that re-exports everything) in performance-critical paths — they can defeat tree-shaking. Import directly from the specific file: import { MyComponent } from './my-component', not import { MyComponent } from './'.Performance Optimisation Examples
<!-- @defer — deferrable views (Angular 17+) -->
<!-- Immediately rendered — critical content -->
<app-task-header [task]="task()"></app-task-header>
<!-- Deferred until viewport enters — below the fold -->
@defer (on viewport) {
<app-task-activity-feed [taskId]="task()._id"></app-task-activity-feed>
} @loading (minimum 300ms) {
<app-skeleton-loader lines="5"></app-skeleton-loader>
} @placeholder {
<div class="placeholder">Activity feed loading...</div>
} @error {
<p>Could not load activity feed.</p>
}
<!-- Deferred until browser idle -->
@defer (on idle) {
<app-task-analytics [taskId]="task()._id"></app-task-analytics>
}
<!-- Deferred until user clicks -->
@defer (on interaction(commentsBtn)) {
<app-comments [taskId]="task()._id"></app-comments>
}
<button #commentsBtn>Show Comments</button>
// NgOptimizedImage — Angular Image directive
import { NgOptimizedImage } from '@angular/common';
@Component({
standalone: true,
imports: [NgOptimizedImage],
template: `
<!-- LCP hero image -- fetchpriority=high, no lazy loading -->
<img ngSrc="/assets/hero.jpg"
width="1200" height="600"
priority
alt="Task manager hero">
<!-- User avatar — lazily loaded -->
<img [ngSrc]="user.avatarUrl"
width="40" height="40"
alt="User avatar">
`,
})
export class HeroComponent {}
// CDK Virtual Scroll for large lists
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
standalone: true,
imports: [ScrollingModule],
template: `
<!-- Only renders ~20 DOM nodes for 10,000 items -->
<cdk-virtual-scroll-viewport itemSize="72" style="height: 600px">
<app-task-card
*cdkVirtualFor="let task of tasks(); trackBy: trackById"
[task]="task">
</app-task-card>
</cdk-virtual-scroll-viewport>
`,
})
export class VirtualTaskListComponent {
tasks = input.required<Task[]>();
trackById = (_: number, t: Task) => t._id;
}
// Heavy computation outside Angular zone
import { inject, NgZone } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class HeavyComputeService {
private ngZone = inject(NgZone);
computeAsync<T>(fn: () => T): Promise<T> {
return new Promise(resolve => {
this.ngZone.runOutsideAngular(() => {
const result = fn();
this.ngZone.run(() => resolve(result));
});
});
}
}
How It Works
Step 1 — @defer Creates Separate JavaScript Chunks at Build Time
When Angular compiles a template with a @defer block, the build tool creates a separate JavaScript chunk for the deferred component and all its dependencies. The main bundle contains only the trigger logic and the placeholder/loading/error templates. When the trigger fires (viewport intersection, user idle, timer), Angular dynamically imports the chunk and renders the component — automatic code splitting with no configuration.
Step 2 — NgOptimizedImage Prevents CLS and Improves LCP
The ngSrc directive replaces src and adds optimisations: it generates a srcset for responsive images, adds loading="lazy" for below-fold images, prevents rendering without explicit width and height (preventing layout shifts), and adds fetchpriority="high" for images marked with priority — improving LCP significantly.
Step 3 — Virtual Scrolling Renders Only Visible Items
CDK Virtual Scroll renders only the DOM nodes currently visible in the viewport plus a small buffer. A list of 10,000 tasks is rendered as approximately 20 DOM nodes. As the user scrolls, Angular destroys off-screen nodes and creates new ones for incoming items. The performance difference between 10,000 real DOM nodes and 20 is enormous — virtual scrolling is required for any list that could exceed ~100 items.
Step 4 — runOutsideAngular Prevents CD Overhead for Heavy Work
ngZone.runOutsideAngular(() => { ... }) executes code outside the zone — Angular does not know it happened and does not run change detection. For CPU-intensive work (sorting thousands of items, computing charts), this prevents change detection from firing after every iteration. After the work completes, call ngZone.run(() => updateSignals()) to re-enter the zone and apply results.
Step 5 — Tree-Shaking Requires Named Imports
Angular’s production build removes code that is imported but never called. This works on ES module named exports — importing { map } from 'rxjs/operators' is tree-shakeable. Barrel files and namespace imports (import * as _ from 'lodash') defeat tree-shaking. Always import specific named exports from specific module paths.
Common Mistakes
Mistake 1 — Not using lazy loading
❌ Wrong — all features downloaded on first page load:
import { AdminDashboard } from './features/admin/admin.component';
const routes = [{ path: 'admin', component: AdminDashboard }]; // always in bundle
✅ Correct — lazy load all feature routes:
const routes = [{
path: 'admin',
loadComponent: () => import('./features/admin/admin.component').then(m => m.AdminDashboard),
}];
Mistake 2 — Importing large libraries entirely
❌ Wrong — imports all of lodash (~70KB):
import _ from 'lodash';
const sorted = _.sortBy(tasks, 'priority');
✅ Correct — import only what you need:
import sortBy from 'lodash/sortBy'; // ~4KB
const sorted = sortBy(tasks, 'priority');
Mistake 3 — Rendering large lists without virtual scrolling
❌ Wrong — 5000 DOM nodes:
<ul><li *ngFor="let task of allTasks">{{ task.title }}</li></ul>
✅ Correct — virtual scrolling:
<cdk-virtual-scroll-viewport itemSize="48" style="height:600px">
<li *cdkVirtualFor="let task of allTasks">{{ task.title }}</li>
</cdk-virtual-scroll-viewport>
Quick Reference
| Optimisation | Implementation |
|---|---|
| Lazy route loading | loadComponent: () => import(...).then(m => m.Comp) |
| Defer non-critical UI | @defer (on viewport) { <app-heavy> } @loading { ... } |
| Optimised images | <img ngSrc="..." width="X" height="Y"> |
| Virtual scrolling | <cdk-virtual-scroll-viewport itemSize="H"> |
| Bundle analysis | ng build --stats-json + webpack-bundle-analyzer |
| Heavy compute off-thread | ngZone.runOutsideAngular(() => { ... }) |
| LCP image priority | <img ngSrc="..." priority> |