Performance Optimization
Angular apps are fast when they do less work: fewer checks, less JavaScript up front, smaller images and shorter lists. Most wins come from a few well-known techniques.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Learning objectives#
- Reduce change detection work with OnPush, signals and zoneless
- Render lists efficiently with track and virtual scrolling
- Load less code with lazy routes and @defer
- Optimise images and watch bundle budgets
Explanation#
Signals tell Angular exactly what changed. Combine them with ChangeDetectionStrategy.OnPush (the default recommendation for new components) so components only update when their inputs or signals change.
Zoneless change detection (provideZonelessChangeDetection()) removes zone.js entirely; Angular updates on signal changes and events instead. It is smaller and faster, and works best with signal-based code.
Lists: always track by a stable id; for thousands of rows use CDK virtual scrolling (cdk-virtual-scroll-viewport) so only visible rows exist in the DOM.
Ship less JavaScript: lazy-load routes, @defer heavy widgets, and avoid large libraries for small tasks.
Images: NgOptimizedImage (ngSrc) adds width/height, lazy loading, priority hints and srcset generation.
Measure: Angular DevTools profiler, Lighthouse, and budgets in angular.json that fail the build when bundles grow too large.
Syntax#
@Component({ changeDetection: ChangeDetectionStrategy.OnPush, … })
// app.config.ts
providers: [provideZonelessChangeDetection()]
<img ngSrc="/hero.jpg" width="1200" height="600" priority />
Code example: An OnPush, signal-driven list with optimised images#
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
type Product = { id: number; name: string; price: number; image: string };
@Component({
selector: 'app-product-tile',
imports: [NgOptimizedImage],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article>
<img [ngSrc]="product().image" width="160" height="120" [alt]="product().name" />
<h4>{{ product().name }}</h4>
<p>₹{{ product().price }}</p>
</article>
`,
})
export class ProductTile {
product = input.required<Product>();
}
@Component({
selector: 'app-product-grid',
imports: [ProductTile],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input #q (input)="query.set(q.value)" placeholder="Filter" />
<p>{{ visible().length }} of {{ products().length }} products</p>
<section class="grid">
@for (p of visible(); track p.id) {
<app-product-tile [product]="p" />
}
</section>
@defer (on viewport) {
<p>⭐ Recommendations load only when scrolled into view.</p>
} @placeholder {
<p>Recommendations</p>
}
`,
})
export class ProductGrid {
query = signal('');
products = signal<Product[]>(
Array.from({ length: 60 }, (_, i) => ({ id: i + 1, name: `Product ${i + 1}`, price: 50 + i * 10, image: `https://picsum.photos/seed/${i + 1}/160/120` })),
);
visible = computed(() => {
const q = this.query().toLowerCase();
return this.products().filter(p => p.name.toLowerCase().includes(q));
});
}
// app.config.ts: providers: [provideZonelessChangeDetection(), provideRouter(routes, withPreloading(PreloadAllModules))]
The grid shows 60 tiles; images below the fold load lazily with fixed sizes (no layout shift). Typing “Product 1” re-filters via a computed signal — only tiles that are added or removed are touched, thanks to track and OnPush. The recommendations section is not loaded until scrolled into view.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Both components use OnPush, so Angular skips them unless an input or a signal they read changes.
- visible is computed: it recalculates only when query or products change.
- track p.id lets Angular keep existing tiles instead of re-creating them.
- NgOptimizedImage enforces width/height and lazy-loads images by default.
- @defer delays the recommendations code and work until needed.
Real-world use case#
A marketplace listing page went from a Lighthouse score of 58 to 91 by lazy-loading routes, switching tiles to OnPush + signals, deferring reviews and using NgOptimizedImage for product photos.
Common mistakes#
{{ calculateTotals() }} runs on every check. Use computed signals or pure pipes.
Changing an object in place does not change its reference, so OnPush children do not update. Create new objects.
Huge DOM trees are slow regardless of Angular. Paginate or virtual-scroll.
Best practices#
- OnPush + signals for all new components
- Consider zoneless for new apps
- Lazy routes, @defer, NgOptimizedImage
- Set and respect bundle budgets
Remember this#
Signals + OnPush = check only what changed.
track by id; virtual-scroll huge lists.
Less JS up front: lazy routes and @defer.
Practice exercise#
Your task: Convert a component that recalculates an expensive total in a template method call to use a computed signal and OnPush.
Need a hint?
Show solution
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-cart-total',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Total: ₹{{ total() }}</p>`, // was {{ calculateTotal() }}
})
export class CartTotal {
items = signal([{ price: 20, qty: 3 }, { price: 60, qty: 1 }]);
total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0)); // 120
}
Mini quiz#
What does ChangeDetectionStrategy.OnPush do?
Want more? Practise all Angular MCQs · Angular interview questions
My notes#
Saved in this browser.