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:
| Kind | Question | Main tools in this lesson |
|---|---|---|
| Load performance | How fast does the first page appear? | Lazy loading, bundle optimization |
| Runtime performance | How 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 buildoutput: 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()andperformance.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 builduses the production configuration by default. It removes unused code (tree-shaking), shortens names (minification) and removes development checks. - Use budgets. In
angular.json,budgetswarn 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
asyncpipe 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:
| Strategy | When Angular checks the component |
|---|---|
Default | On every change detection run |
OnPush | Only 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.
- An
@Input()gets a new value. For objects and arrays, "new" means a new object, not a changed property of the same object. - An event happens in the component's template, such as
(click). - A signal or an
asyncpipe 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
OnPushfor 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
trackin lists, so Angular reuses the existing elements (next section). - Split large components into smaller ones, so
OnPushcan 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
NgForfrom@angular/commonand add it toimports. - The
trackByfunction gets the index and the item, and returns the value that identifies the item. - Without
trackBy,*ngFortracks the object itself, liketrack 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
@iffor hidden parts. Content inside a false@ifis 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
| Area | Best practice |
|---|---|
| Measurement | Measure with Lighthouse, the Performance tab and the Angular DevTools Profiler before and after a change |
| Loading | Lazy-load large and rarely used pages, and keep budgets in angular.json |
| Bundle | Build for production, import only what you use, check library sizes, optimize images |
| Change detection | Use signals and OnPush, and create new objects instead of changing old ones |
| Templates | No heavy methods in templates; use computed() and pure pipes |
| Lists | Always track by a unique ID; show long lists in pages |
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
Changing a property of an input object in an OnPush app | The child shows old data | Create a new object: { ...obj, name: 'x' } |
| A heavy method in the template | The page becomes slow after every click | Use a computed signal |
track item or *ngFor without trackBy for server data | Every reload creates all rows again | Track by a unique ID |
| All pages in the first download | The first page loads slowly | Lazy-load large pages |
| Optimizing without measuring | More complex code, but no faster app | Measure 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.
Defaultcomponents are checked on every run;OnPushcomponents 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 withtrackin@forortrackByin*ngFor.
Next: Capstone - Employee Management System, a complete app that combines everything you learned.