Performance Best Practices
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-jsonwrites abrowser-stats.jsonfile 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:
- Lazy routes with
loadComponentandloadChildren, so each section downloads only when visited (Route Parameters, Guards & Lazy Loading). @deferblocks for heavy components inside a page, such as charts and maps (Deferrable Views with @defer).- Budgets in
angular.json, so the build warns you when the bundle grows:
"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:
// 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());
}
}// 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:
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 {}widthandheightare required (or usefillinside 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.
prioritymarks 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
debounceTimein RxJS & Signals Interop). - For
scroll,mousemoveandresizehandlers, 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 serveis unoptimised by design. Testng buildoutput. - Calling methods or creating arrays in templates. Use
computedsignals. - Tracking by object or
$indexin dynamic lists. Track by a stable ID. - Huge images and missing dimensions. Use
NgOptimizedImagewithwidth,heightandpriorityfor the main image.
Key points
- Measure with Lighthouse, Angular DevTools and
ng build --stats-jsonbefore and after every change. - Shrink the initial bundle with lazy routes,
@defer, budgets and careful imports. - Signals,
computedand immutable inputs let OnPush and zoneless change detection skip unnecessary work. - Track
@forlists 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
// (1) replace the repeated method call with a computed signal
protected todaysOrders = computed(() =>
this.orders().filter((o) => o.date === this.today()),
);<!-- (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?
Development builds are unoptimised; always measure production output.
2. Why is computed better than calling a filtering method in a template?
Template methods run on every check; computed signals are memoised.
3. What does the priority attribute on an NgOptimizedImage image do?
priority is for the LCP image so it is fetched early.
4. Which tool shows what makes your JavaScript bundle large?
The stats file lists every module and its size.
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
