EthicsComputerCourses
Join training +91 90532 10052

Performance Best Practices

Angular · Lesson 36 of 41 Advanced 7 min read Updated

Many of your users will open your app on a mid-range phone, on a mobile network that drops to slow speeds in a lift or a train. A fast app is not a luxury there; it is the difference between a user who orders lunch and one who gives up. Performance work has two parts: loading (how quickly the first screen appears) and runtime (how smoothly the app responds after that).

This lesson collects the habits that matter most in Angular 22. Many of them you already know from earlier lessons.

Measure before you change anything

Guessing wastes time. Measure first, change one thing, measure again.

  • Lighthouse (in Chrome DevTools) scores loading performance and lists problems such as large images or unused JavaScript. Test the production build, never ng serve.
  • Angular DevTools, a browser extension from the Angular team, shows your component tree and a profiler that records which components were checked and how long each took.
  • The Performance panel in Chrome DevTools shows long tasks that freeze the page.
  • ng build --stats-json writes a browser-stats.json file that you can load into the esbuild bundle analyzer to see which packages make your bundle big.

Always build for production when measuring: ng build (the production configuration is the default) minifies code, removes development checks and enables optimisations.

Keep the initial bundle small

The JavaScript needed for the first screen is the biggest loading cost. Angular gives you three main tools to shrink it:

JSON
"budgets": [
  { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
  { "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]

These are the defaults in a new project. Tighten them as your app matures, so nobody adds a 300 kB library to the first screen without noticing. If lazy chunks are small, you can download them in the background after start-up with provideRouter(routes, withPreloading(PreloadAllModules)), so later navigation feels instant.

Watch your imports too. Importing a whole utility library for one function, or a date library with every locale, can add hundreds of kilobytes. Prefer modern built-ins (Intl.DateTimeFormat, Array.prototype.toSorted, structuredClone) or small, tree-shakable packages.

Let signals and OnPush do the work

At runtime, the biggest cost is usually doing work that is not needed. Angular 22 already helps: apps are zoneless and components are OnPush by default, so only components whose signals or inputs changed are checked. Your job is to keep it that way:

  • Store state in signals, so Angular knows exactly what changed.
  • Use computed signals for derived values instead of methods called from templates.
  • Keep inputs immutable: pass new arrays and objects, never mutate existing ones.

Compare these two lists. Both show the same result:

TypeScript
// Slow: the method runs on every check, and track uses the whole object
@Component({
  selector: 'app-slow-list',
  template: `
    <p>{{ getVisible().length }} items</p>
    @for (p of getVisible(); track p) {
      <div>{{ p.name }}</div>
    }
  `,
})
export class SlowList {
  readonly products = input.required<Product[]>();
  readonly category = input('all');

  protected getVisible() {
    return this.products().filter((p) => this.category() === 'all' || p.category === this.category());
  }
}
TypeScript
// Fast: computed caches the filtered list until an input changes
@Component({
  selector: 'app-product-list',
  template: `
    <p>{{ visible().length }} items, total ₹{{ total() }}</p>
    @for (p of visible(); track p.id) {
      <div>{{ p.name }} - ₹{{ p.price }}</div>
    }
  `,
})
export class ProductList {
  readonly products = input.required<Product[]>();
  readonly category = input('all');

  protected visible = computed(() =>
    this.category() === 'all'
      ? this.products()
      : this.products().filter((p) => p.category === this.category()),
  );
  protected total = computed(() => this.visible().reduce((sum, p) => sum + p.price, 0));
}

The slow version filters the whole list twice on every check (once for the count, once for the loop). Worse, filter() returns a new array each time, and tracking by the object means that whenever the products are reloaded from the server, every row is destroyed and recreated. The fast version filters only when products or category changes, and track p.id lets Angular keep existing rows.

Images

Images are often the heaviest part of a page. The NgOptimizedImage directive from @angular/common applies good practices automatically. Use ngSrc instead of src:

TypeScript
import { NgOptimizedImage } from '@angular/common';
import { Component } from '@angular/core';

@Component({
  selector: 'app-hero-banner',
  imports: [NgOptimizedImage],
  template: `
    <img ngSrc="/images/canteen-hero.jpg" width="1200" height="400" priority alt="Students eating lunch in the canteen" />
    <img ngSrc="/images/thali.jpg" width="400" height="300" alt="Veg thali" />
  `,
})
export class HeroBanner {}
  • width and height are required (or use fill inside a sized container). They reserve space, so the page does not jump when the image loads; this jumping is measured as layout shift.
  • Images are lazy-loaded by default, so images far down the page do not slow the first screen.
  • priority marks the main, above-the-fold image (the LCP element, Largest Contentful Paint) so it loads first. In development, the directive warns you if your LCP image lacks it.

Also serve modern formats such as WebP or AVIF and sensible sizes; a 4000-pixel photo from a phone camera should never reach a 400-pixel card.

Long lists and frequent events

  • For lists with thousands of rows, render only what is visible. The Angular CDK (@angular/cdk/scrolling) provides a virtual scroll viewport for this, or use pagination.
  • For search boxes, wait for a pause in typing before sending requests (see debounceTime in RxJS & Signals Interop).
  • For scroll, mousemove and resize handlers, do as little as possible, and update signals only when the value really changes.

Server rendering for the first paint

For public pages, SSR or prerendering sends finished HTML, so content appears before JavaScript runs. Combine it with @defer (hydrate on ...) so parts of the page become interactive only when needed.

Common mistakes

  • Optimising without measuring. Profile first; the slow part is rarely where you expect.
  • Measuring the development build. ng serve is unoptimised by design. Test ng build output.
  • Calling methods or creating arrays in templates. Use computed signals.
  • Tracking by object or $index in dynamic lists. Track by a stable ID.
  • Huge images and missing dimensions. Use NgOptimizedImage with width, height and priority for the main image.

Key points

  • Measure with Lighthouse, Angular DevTools and ng build --stats-json before and after every change.
  • Shrink the initial bundle with lazy routes, @defer, budgets and careful imports.
  • Signals, computed and immutable inputs let OnPush and zoneless change detection skip unnecessary work.
  • Track @for lists by a stable ID and avoid methods in templates.
  • Use NgOptimizedImage (ngSrc, width, height, priority) and SSR or prerendering for a fast first paint.

Exercise

A canteen dashboard is slow. It has: (1) a template that calls getTodaysOrders() three times; (2) @for (o of orders(); track $index) over orders that reload from the server every 30 seconds; (3) a 1.5 MB banner image loaded with a plain <img src>; (4) a sales chart library imported at the top of the dashboard, shown at the bottom of the page. Describe and write the fix for each problem.

Show solution
TypeScript
// (1) replace the repeated method call with a computed signal
protected todaysOrders = computed(() =>
  this.orders().filter((o) => o.date === this.today()),
);
HTML
<!-- (2) track by a stable ID so reloaded rows are reused -->
@for (o of todaysOrders(); track o.id) {
  <app-order-row [order]="o" />
}

<!-- (3) optimised banner image: compressed WebP, reserved size, loaded first -->
<img ngSrc="/images/banner.webp" width="1200" height="300" priority alt="Canteen banner" />

<!-- (4) load the chart only when it scrolls into view -->
@defer (on viewport) {
  <app-sales-chart />
} @placeholder {
  <div class="chart-box">Sales chart</div>
}

The computed runs the filter once per change instead of three times per check. Tracking by o.id stops Angular from rebuilding every row on each 30-second refresh. The banner is compressed and served through NgOptimizedImage with fixed dimensions, and the chart library moves into a lazy chunk, so the first screen downloads less.

Quick Quiz

1. Which build should you measure with Lighthouse?

2. Why is computed better than calling a filtering method in a template?

3. What does the priority attribute on an NgOptimizedImage image do?

4. Which tool shows what makes your JavaScript bundle large?

Learn Angular with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call