Angular Performance Optimization: Practical Techniques
Practical, production-tested techniques for speeding up Angular applications — from change detection to bundle size.
Most Angular performance problems don’t come from Angular itself — they come from change detection running far more often than it needs to, templates doing work they shouldn’t, and bundles shipping code nobody is using yet. None of the fixes below are exotic. They’re the same handful of techniques that show up in every performance audit I’ve done on a mid-to-large Angular app, and they compound well together.
Change Detection: Stop Checking Everything, Everywhere
By default, Angular’s change detection walks the entire component tree on every event — a click, an HTTP response, a setTimeout, a router navigation. For a small app that’s irrelevant. For a dashboard with a few hundred bindings, it’s the single biggest cost you’ll find in the profiler.
ChangeDetectionStrategy.OnPush tells Angular to skip a component (and its subtree) unless one of a few specific things happens: an @Input() reference changes, an event originates from inside the component, or an observable bound with the async pipe emits.
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-order-row',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<tr>
<td>{{ order.id }}</td>
<td>{{ order.total | currency }}</td>
<td>{{ order.status }}</td>
</tr>
`,
})
export class OrderRowComponent {
@Input() order!: Order;
}
The catch: OnPush relies on reference equality. If a parent mutates an array in place (orders.push(newOrder)) instead of replacing it (orders = [...orders, newOrder]), the child never sees the change. This is the most common reason teams try OnPush, see a component silently stop updating, and revert back to default change detection instead of fixing the mutation. Treat your component inputs as immutable data and this stops being an issue.
trackBy: Don’t Let Angular Guess
When you render a list with @for (or the older *ngFor), Angular needs a way to know which DOM nodes correspond to which data items across re-renders. Without help, it falls back to identity comparison, which means any array replacement — even one that only added a single item — can cause Angular to tear down and rebuild DOM nodes it didn’t need to touch.
@Component({
template: `
@for (order of orders(); track order.id) {
<app-order-row [order]="order" />
}
`,
})
export class OrderListComponent {
orders = input.required<Order[]>();
}
The new control-flow syntax actually forces you to specify a track expression, which is a quiet but meaningful improvement over *ngFor — it’s extremely easy to forget trackBy with the old syntax and not notice until a list with inline form state (checkboxes, expanded rows) starts resetting itself on every update.
Lazy-Loaded Routes
Every route that isn’t needed on initial load should be its own chunk. This is the highest-leverage, lowest-effort change you can make to initial bundle size, and it’s a prerequisite for a good Largest Contentful Paint score on any app with more than a couple of screens.
export const routes: Routes = [
{ path: '', component: DashboardComponent },
{
path: 'reports',
loadComponent: () =>
import('./reports/reports.component').then((m) => m.ReportsComponent),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];
loadComponent for a single standalone component, loadChildren for a whole feature’s route tree. If you’re still on NgModules, the module-based loadChildren syntax works the same way — the point is the same either way: nothing downloads until the user navigates there. See Angular Standalone Components: Why They Matter for more on how standalone APIs make this simpler than the old NgModule lazy-loading dance.
The @defer Block
Lazy routes solve navigation-level splitting. @defer solves the problem inside a single view: you often have components that are expensive but not immediately visible — a comments section below the fold, a chart that only renders after data loads, a modal that’s rarely opened.
@Component({
template: `
@defer (on viewport) {
<app-analytics-chart [data]="chartData()" />
} @placeholder {
<div class="chart-skeleton"></div>
} @loading (minimum 200ms) {
<app-spinner />
}
`,
})
export class DashboardComponent {}
@defer moves the deferred component and its dependencies into a separate chunk automatically — no manual dynamic import needed — and gives you fine control over the trigger (on viewport, on interaction, on idle, on timer(2s), or a combination). This is one of the most practical additions Angular has shipped in recent versions, covered in more depth in Angular’s @defer Explained.
Don’t Call Functions From Templates
This one is subtle and shows up constantly in code review:
// Bad — runs on every change detection cycle, for every row
@Component({
template: `<span>{{ formatStatus(order.status) }}</span>`,
})
export class OrderRowComponent {
formatStatus(status: string) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
}
Angular has no way to know formatStatus is pure. It re-invokes it on every change detection pass, for every instance of the component, whether or not status changed. In a list of 500 rows checked a few times a second during scroll or animation, that adds up to real, measurable jank.
The fix is to precompute the value, use a pipe (pipes are memoized on their inputs by default), or push the transform into a signal/computed value:
// Good — pure, memoized pipe
@Pipe({ name: 'statusLabel' })
export class StatusLabelPipe implements PipeTransform {
transform(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
}
<span>{{ order.status | statusLabel }}</span>
Signals and Fine-Grained Reactivity
Signals change the performance story in a more structural way than OnPush does. With OnPush plus the async pipe, Angular still re-checks the whole component’s template when any tracked input changes. With signals, Angular can — increasingly, as more of the framework becomes signal-aware — update only the specific DOM bindings that actually depend on the signal that changed, without re-running the rest of the component’s change detection.
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Total: {{ total() }}</p>
<p>Items: {{ itemCount() }}</p>
`,
})
export class CartSummaryComponent {
private cart = inject(CartService);
items = this.cart.items;
itemCount = computed(() => this.items().length);
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
}
Because computed() values are only recalculated when their dependencies actually change, and signal reads in templates are tracked individually, you get memoization and fine-grained updates without writing any manual distinctUntilChanged or shareReplay logic. This doesn’t replace OnPush — use both.
Virtual Scrolling for Long Lists
Rendering 10,000 DOM rows is slow no matter how well-optimized each row’s change detection is. The Component Dev Kit’s CdkVirtualScrollViewport only renders the rows currently in (or near) the viewport:
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<div *cdkVirtualFor="let order of orders" class="row">
{{ order.id }} — {{ order.total | currency }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`.viewport { height: 600px; }`],
})
export class OrderListComponent {
orders: Order[] = [];
}
itemSize needs a fixed or reasonably estimated row height for the math to work. If rows vary wildly in height, look at the CDK’s experimental autosize strategy, or consider pagination instead — virtual scroll with highly variable row heights tends to cause visible jumpiness.
Bundle Analysis Basics
Before guessing at what’s bloating your bundle, measure it. The Angular CLI can emit stats for source-map-explorer or webpack-bundle-analyzer:
ng build --stats-json
npx webpack-bundle-analyzer dist/<project-name>/stats.json
What to look for: duplicate library versions (a common one is two versions of moment or lodash pulled in by different dependencies), large libraries imported wholesale when you only use a fraction of them (import * as _ from 'lodash' instead of import debounce from 'lodash/debounce'), and polyfills you no longer need for your target browser list. For a broader, framework-agnostic rundown of bundle and runtime performance work, see Frontend Performance Optimization Techniques.
Common Mistakes
- Forgetting
trackBy/trackon lists that re-render frequently, causing full DOM rebuilds. - Mutating inputs passed into
OnPushcomponents instead of replacing references, then “fixing” it by removingOnPushrather than the mutation. - Calling methods or complex getters directly in templates, re-running expensive logic every change detection cycle.
- Eagerly loading every route because lazy loading “can be done later” — it rarely is, and the bundle just keeps growing.
- Virtual scrolling lists that are already small. It adds complexity for no benefit under a few hundred items; profile first.
Best Practices
- Default new components to
OnPushand treat all@Input()data as immutable. - Use
@deferfor anything below the fold or behind an interaction. - Prefer signals and
computed()for derived state over manual subscriptions and manual re-renders. - Run a bundle analysis as part of your release checklist, not just when something feels slow.
- Profile with Chrome DevTools’ Performance tab and Angular DevTools before optimizing — guessing wastes time on the wrong fix.
FAQ
Does OnPush ever make things slower?
No — at worst it’s neutral, since it’s strictly reducing the set of components Angular checks. The risk isn’t performance, it’s correctness: a missed mutation means a UI that silently doesn’t update.
Do I need OnPush if I’m already using signals everywhere?
Yes, still set it explicitly. Signals improve what happens within a component’s reactivity, but OnPush is what prevents Angular from needlessly re-checking the component’s subtree from external triggers.
Is @defer a replacement for lazy-loaded routes?
No, they solve different problems — routes split your app by navigation, @defer splits a single view by visibility/interaction. Use both.
Related Reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
NgRx vs Signals: Choosing State Management
When a full NgRx store still earns its complexity in an Angular app, and when Signals-based state is simpler and sufficient.
RxJS vs Signals in Modern Angular Applications
How RxJS and Signals coexist in modern Angular apps, and practical patterns for using both together.
Angular Standalone Components: Why They Matter
Why standalone components became Angular's default architecture, and how to structure an app around them.