Performance Optimization in Angular

By Syncfusion®, Inc. · Last updated

Making apps fast: measuring performance, lazy loading, bundle optimization, change detection with Default and OnPush, track in @for, trackBy in *ngFor, and rendering best practices.

Why Performance Matters

Performance is how fast your app loads and how fast it reacts to the user. A slow app loses users:

  • Users leave a page that takes more than a few seconds to show content.
  • Slow reactions to clicks and typing make an app feel broken.
  • Many users have slow phones and slow mobile networks, not a fast developer computer.
  • Search engines rank fast pages higher.

Angular apps have two kinds of performance:

KindQuestionMain tools in this lesson
Load performanceHow fast does the first page appear?Lazy loading, bundle optimization
Runtime performanceHow fast does the app react after it has loaded?Change detection, OnPush, track

Real-World Example

An e-commerce admin panel shows a table of 2,000 orders. Every click on any button makes Angular check all 2,000 rows again, and the page freezes for half a second.

The team switches the row component to OnPush and adds track order.id to the list. Now a click checks only the rows that changed, and the page reacts at once.

Performance Monitoring Basics

Measure first, then optimize. Without measurements, you cannot know which part is slow, or if your change helped.

Tools that are already in the browser and in Angular:

  • ng build output: the size of each JavaScript file. Look at it after every bigger change.
  • Lighthouse (in Chrome's developer tools): gives the page a performance score and lists problems, such as large files.
  • The Performance tab of the developer tools: records what the browser does during a click, and shows slow functions.
  • Angular DevTools Profiler: shows each change detection run, how long it took and which components it checked.
  • console.time() and performance.now() measure how long your own code takes.

Measuring the time of a function

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="sortOrders()">Sort 200,000 orders</button>
    <p>{{ status }}</p>
  `,
})
export class App {
  status = 'Open the browser console (F12), then click the button.';

  sortOrders() {
    const orders = Array.from({ length: 200000 }, () => Math.random());

    const start = performance.now();
    orders.sort((a, b) => a - b);
    const ms = performance.now() - start;

    console.log(`Sorting took ${ms.toFixed(1)} ms`);
    this.status = `Sorted ${orders.length} orders. The time is in the console.`;
  }
}

performance.now() returns the time in milliseconds with decimals. The difference between two calls is the time the code needed.

Lazy Loading

You learned lazy loading in the Routing lesson: a page's code is downloaded only when the user opens it. This is the most effective way to make the first load faster.

main.jsfirst downloadreports.jswhen /reports opensadmin.jswhen /admin opensLazy chunks are downloaded only when the user needs them.

Lazy loading splits the app into a small first download and later chunks.

export const routes: Routes = [
  { path: '', component: Home },                                   // in the first download
  { path: 'reports', loadComponent: () => import('./reports/reports').then(m => m.Reports) },
  { path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES) },
];

After ng build, the output lists the lazy files separately under Lazy chunk files. A chunk is one JavaScript file of the build.

  • Lazy-load large pages and pages that few users open: reports, admin, settings.
  • Keep the first page and the most used pages in the first download.

Lazy loading and bundle size matter only for the first visit and for opening new pages. Change detection matters for every click. Check both kinds of performance.

Bundle Optimization

A bundle is the set of JavaScript files that the browser downloads. Smaller bundles load faster.

  • Build for production. ng build uses the production configuration by default. It removes unused code (tree-shaking), shortens names (minification) and removes development checks.
  • Use budgets. In angular.json, budgets warn you, or stop the build, when the bundle grows too large:
"budgets": [
  { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" }
]
  • Import only what you use. Import single functions, such as import { map } from 'rxjs', not whole libraries.
  • Check new libraries before you add them. A large library for one small feature makes every user wait.
  • Optimize images. Use the right size and modern formats. Images are often larger than all the JavaScript.

Angular Change Detection

Change detection is how Angular keeps the screen in sync with the data. During a change detection run, Angular checks the templates of components, compares each value with the last one, and updates the elements whose values changed.

A run starts after the rendering triggers you learned in the Client-Side Rendering lesson:

  • An event in a template, such as (click)
  • A signal that a template reads gets a new value
  • The async pipe receives a new value

Appclick happens hereOrderListDefault: checkedHeaderOnPush: skippedFooterOnPush: skippedDefault components are checked on every run. OnPush components only when they have a reason.

Change detection with Default and OnPush components.

Each check is fast, but an app with hundreds of components and long lists can do a lot of unnecessary work. The rest of this lesson shows how to check less.

Change Detection Strategies

Each component has a change detection strategy. It decides when Angular checks that component. There are two:

StrategyWhen Angular checks the component
DefaultOn every change detection run
OnPushOnly when the component has a reason: a new input value, an event inside it, or a change in a signal or async pipe that its template reads

Set it with the changeDetection property:

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-order-row',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderRow {}

Default Change Detection

With the Default strategy, Angular checks the component on every run, even if nothing in it changed. This is simple and always correct, but it does unnecessary work.

In the next example, checks() is a method called by the template, so it runs every time Angular checks the component. Click the buttons, then click Show check counts.

Default and OnPush components

import { ChangeDetectionStrategy, Component } from '@angular/core';

export const counts = { defaultPanel: 0, onPushPanel: 0 };

@Component({
  selector: 'app-default-panel',
  template: `<p>Default panel {{ checks() }}</p>`,
})
export class DefaultPanel {
  checks() {
    counts.defaultPanel++;
    return '';
  }
}

@Component({
  selector: 'app-onpush-panel',
  template: `<p>OnPush panel {{ checks() }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OnPushPanel {
  checks() {
    counts.onPushPanel++;
    return '';
  }
}

@Component({
  selector: 'app-root',
  imports: [DefaultPanel, OnPushPanel],
  template: `
    <button (click)="clicks = clicks + 1">Click in the parent ({{ clicks }})</button>
    <app-default-panel />
    <app-onpush-panel />
    <button (click)="report = 'Default checked ' + counts.defaultPanel + ' times, OnPush checked ' + counts.onPushPanel + ' times.'">
      Show check counts
    </button>
    <p>{{ report }}</p>
  `,
})
export class App {
  clicks = 0;
  report = '';
  counts = counts;
}

After five clicks in the parent, the Default panel was checked after every click. The OnPush panel was checked only when it was created, because nothing in it changed.

OnPush Change Detection Strategy

With OnPush, Angular skips the component unless one of these happens:

customer.name = "Ravi"{ ...customer, name: "Meena" }Same objectreference unchangedOnPush: no new inputchild is skippedChild shows old nameNew objectnew referenceOnPush: new inputchild is checkedChild shows new name

OnPush checks an input only when it gets a new object.

  1. An @Input() gets a new value. For objects and arrays, "new" means a new object, not a changed property of the same object.
  2. An event happens in the component's template, such as (click).
  3. A signal or an async pipe that its template reads gets a new value.

Rule 1 is the most important one. If the parent changes a property of the same object, an OnPush child does not see it:

OnPush needs a new object

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

export interface Customer {
  name: string;
}

@Component({
  selector: 'app-customer-card',
  template: `<p>Customer: {{ customer.name }}</p>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomerCard {
  @Input() customer!: Customer;
}

@Component({
  selector: 'app-root',
  imports: [CustomerCard],
  template: `
    <button (click)="mutate()">Change the name (same object)</button>
    <button (click)="replace()">Change the name (new object)</button>
    <p>Parent sees: {{ customer.name }}</p>
    <app-customer-card [customer]="customer" />
  `,
})
export class App {
  customer: Customer = { name: 'Asha' };

  mutate() {
    this.customer.name = 'Ravi';  // same object: the OnPush child is skipped
  }

  replace() {
    this.customer = { ...this.customer, name: 'Meena' };  // new object: the child updates
  }
}

Click the first button: the parent shows "Ravi", but the card still shows "Asha". Click the second button: both show "Meena".

{ ...this.customer, name: 'Meena' } copies all properties into a new object and changes name. This is the same "create a new object" rule that you learned for signals.

OnPush and signals work very well together. When a component reads its data from signals, Angular knows exactly when it changed, so OnPush never misses an update. New Angular code often uses OnPush for every component.

Avoiding Unnecessary Change Detection

  • Use OnPush for components that receive their data through inputs or signals.
  • Do not call heavy methods in templates. A method in a template runs on every check of the component. Use a computed signal instead: it recalculates only when its signals change.
  • Use signals for state, so Angular updates only what changed.
  • Use track in lists, so Angular reuses the existing elements (next section).
  • Split large components into smaller ones, so OnPush can skip the parts that did not change.

A method in the template vs a computed signal

import { Component, computed, signal } from '@angular/core';

export const calls = { method: 0, computed: 0 };

@Component({
  selector: 'app-root',
  template: `
    <p>Total (method): {{ totalMethod() }}</p>
    <p>Total (computed): {{ totalComputed() }}</p>
    <button (click)="clicks = clicks + 1">Unrelated click ({{ clicks }})</button>
    <button (click)="prices.update(list => [...list, 10])">Add a $10 item</button>
    <button (click)="report = 'Method ran ' + calls.method + ' times, computed ran ' + calls.computed + ' times.'">
      Show counts
    </button>
    <p>{{ report }}</p>
  `,
})
export class App {
  prices = signal([20, 35, 15]);
  clicks = 0;
  report = '';
  calls = calls;

  totalMethod() {
    calls.method++;
    return this.prices().reduce((sum, p) => sum + p, 0);
  }

  totalComputed = computed(() => {
    calls.computed++;
    return this.prices().reduce((sum, p) => sum + p, 0);
  });
}

Click Unrelated click a few times, then Show counts. The method runs on every check. The computed signal ran only once, because prices did not change. It runs again only after Add a $10 item.

track Expression with @for

When a list changes, Angular must decide which elements to keep, move, create or remove. The track expression tells Angular how to recognize each item. You have used track since the Directives lesson.

track order.idtrack orderReload: new objectssame ids 1, 2, 3Ids matchrows are kept0 rows createdvalues updatedReload: new objectssame ids 1, 2, 3Objects differold rows removed3 rows createdmore work

Tracking by a unique id lets Angular reuse the existing rows.

  • track item.id: Angular recognizes an item by its ID. When the list is loaded again with new objects but the same IDs, Angular keeps the existing elements and only updates their values.
  • track $index: Angular recognizes items by their position. Use it only for simple lists that never change their order.
  • track item: Angular recognizes the object itself. A new object with the same data counts as a new item, so its element is destroyed and created again.

The next example counts how many row components Angular creates. Reload creates new objects with the same IDs, as a server answer would.

track by ID reuses the rows

import { Component, Input } from '@angular/core';

export const created = { byId: 0, byObject: 0 };

@Component({
  selector: 'app-row',
  template: `<span>{{ name }} </span>`,
})
export class Row {
  @Input() name = '';
  @Input() group = 'byId';

  ngOnInit() {
    (created as any)[this.group]++;
  }
}

function loadOrders() {
  return [
    { id: 1, name: 'Order 1001' },
    { id: 2, name: 'Order 1002' },
    { id: 3, name: 'Order 1003' },
  ];
}

@Component({
  selector: 'app-root',
  imports: [Row],
  template: `
    <button (click)="orders = loadOrders()">Reload the orders</button>
    <p>track order.id:
      @for (order of orders; track order.id) {
        <app-row [name]="order.name" group="byId" />
      }
    </p>
    <p>track order:
      @for (order of orders; track order) {
        <app-row [name]="order.name" group="byObject" />
      }
    </p>
    <button (click)="report = 'Rows created: track order.id = ' + created.byId + ', track order = ' + created.byObject">
      Show counts
    </button>
    <p>{{ report }}</p>
  `,
})
export class App {
  orders = loadOrders();
  loadOrders = loadOrders;
  report = '';
  created = created;
}

Click Reload the orders twice, then Show counts. With track order.id, the 3 rows were created once. With track order, Angular created 3 new rows on every reload.

trackBy with *ngFor

Older Angular code uses the *ngFor directive instead of @for. You will see it in many existing projects. It works the same way, but the tracking is optional and is written as a trackBy function:

<!-- Old syntax -->
<li *ngFor="let order of orders; trackBy: trackById">{{ order.name }}</li>

<!-- New syntax -->
@for (order of orders; track order.id) {
  <li>{{ order.name }}</li>
}
  • Import NgFor from @angular/common and add it to imports.
  • The trackBy function gets the index and the item, and returns the value that identifies the item.
  • Without trackBy, *ngFor tracks the object itself, like track order. This is a common cause of slow lists.

*ngFor with trackBy

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

interface Order {
  id: number;
  name: string;
}

@Component({
  selector: 'app-root',
  imports: [NgFor],
  template: `
    <button (click)="reload()">Reload</button>
    <p>Updated {{ reloads }} times</p>
    <ul>
      <li *ngFor="let order of orders; trackBy: trackById">{{ order.name }}</li>
    </ul>
  `,
})
export class App {
  orders: Order[] = this.load();
  reloads = 0;

  load(): Order[] {
    return [
      { id: 1, name: 'Order 1001' },
      { id: 2, name: 'Order 1002' },
    ];
  }

  reload() {
    this.orders = this.load();
    this.reloads++;
  }

  trackById(index: number, order: Order) {
    return order.id;
  }
}

Prefer @for in new code: track is required there, so you cannot forget it.

Optimizing Component Rendering

  • Render less. Show long lists in pages (for example 50 rows per page) instead of all rows at once.
  • Keep templates simple. Calculate values in computed signals, not in template expressions.
  • Use pure pipes to format values. You learned pipes in the Pipes lesson. Angular runs a pure pipe again only when its input changes, like a computed signal.
  • Use @if for hidden parts. Content inside a false @if is not created at all. Content hidden with CSS is still created and checked.
  • Avoid deep component trees for simple layouts. Each component adds work during checks.

Do not optimize everything "just in case". Measure first. Most components are fast enough with the default settings, and extra optimizations make the code harder to read.

Performance Best Practices

AreaBest practice
MeasurementMeasure with Lighthouse, the Performance tab and the Angular DevTools Profiler before and after a change
LoadingLazy-load large and rarely used pages, and keep budgets in angular.json
BundleBuild for production, import only what you use, check library sizes, optimize images
Change detectionUse signals and OnPush, and create new objects instead of changing old ones
TemplatesNo heavy methods in templates; use computed() and pure pipes
ListsAlways track by a unique ID; show long lists in pages

Common Mistakes

MistakeWhat you seeFix
Changing a property of an input object in an OnPush appThe child shows old dataCreate a new object: { ...obj, name: 'x' }
A heavy method in the templateThe page becomes slow after every clickUse a computed signal
track item or *ngFor without trackBy for server dataEvery reload creates all rows againTrack by a unique ID
All pages in the first downloadThe first page loads slowlyLazy-load large pages
Optimizing without measuringMore complex code, but no faster appMeasure first, then optimize the slow part

Check Yourself

Exercises

What to Remember

  • Measure first. Improve load performance with lazy loading and small production bundles, and runtime performance by doing less change detection work.
  • Default components are checked on every run; OnPush components only when an input gets a new value, an event happens in them, or a signal they read changes. Always create new objects instead of changing old ones.
  • Avoid heavy methods in templates (use computed()), and track list items by a unique ID with track in @for or trackBy in *ngFor.

Next: Capstone - Employee Management System, a complete app that combines everything you learned.