Angular · Production

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.

Advanced 6 min Lesson 35 of 38Examples target Angular 20 with standalone components. Each lesson states the version it was verified against.
Before you start:SignalsLazy Loading
0/38 in Angular
On this page · 6 min · Advanced

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#

TypeScript Illustrative
@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#

TypeScriptproduct-grid.ts Illustrative
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))]
Expected output
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#

  1. Both components use OnPush, so Angular skips them unless an input or a signal they read changes.
  2. visible is computed: it recalculates only when query or products change.
  3. track p.id lets Angular keep existing tiles instead of re-creating them.
  4. NgOptimizedImage enforces width/height and lazy-loads images by default.
  5. @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#

Function calls with heavy work in templates

{{ calculateTotals() }} runs on every check. Use computed signals or pure pipes.

Mutating inputs with OnPush

Changing an object in place does not change its reference, so OnPush children do not update. Create new objects.

Rendering thousands of rows

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?
total = computed(() => this.items().reduce(...)); changeDetection: ChangeDetectionStrategy.OnPush.
Show solution
TypeScript Illustrative
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#

Performance Optimization quizMultiple choice Question 1 of 3

What does ChangeDetectionStrategy.OnPush do?

Score so far: 0/0

Want more? Practise all Angular MCQs · Angular interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.