Services AI Workflows Work Blog Hire Me
Blog Angular

5 Angular 17 performance wins that actually matter

OnPush everywhere, signal-based state, virtual scrolling, and two others I use on every enterprise project.

Jaks May 2026 6 min read

I've spent the last few years building large Angular applications for enterprise clients — data-heavy dashboards, real-time monitoring tools, document processors. Every project starts the same way: default change detection, BehaviorSubject everywhere, *ngFor without trackBy. By tip 3 or 4 in this list, the difference in rendering performance is visible without a profiler.

Here are the five things I do on every project.

1. OnPush everywhere

1

Angular's default change detection checks every component on every event. ChangeDetectionStrategy.OnPush cuts this to only check a component when its @Input() references change, an async pipe emits, or you call markForCheck() explicitly. On a tree with 200+ components, this is the single biggest win.

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

@Component({
  selector: 'app-invoice-row',
  templateUrl: './invoice-row.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InvoiceRowComponent {
  @Input() invoice!: Invoice;
}

The cost: you have to be more deliberate about when data changes. Mutating an object in place won't trigger change detection — you need to produce a new reference. That discipline is worth it for the performance gain, and it also makes your code easier to reason about.

2. Signals instead of BehaviorSubject

2

Angular 17's signals (signal(), computed(), effect()) are a leaner alternative to BehaviorSubject for local and shared state. They integrate directly with Angular's change detection — when a signal value changes, only the components that read it are marked dirty, with no manual subscription management.

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

// In a service or component:
const invoices = signal<Invoice[]>([]);
const totalAmount = computed(() =>
  invoices().reduce((sum, inv) => sum + inv.total, 0)
);

// To update:
invoices.update(prev => [...prev, newInvoice]);

// In the template — no async pipe needed:
// {{ totalAmount() }}

computed() memoises automatically — it only recalculates when one of its signal dependencies changes. Replace your derived BehaviorSubject chains with computed() and you get lazy evaluation for free.

3. Virtual scrolling with CDK

3

Rendering a list of 5,000 rows creates 5,000 DOM nodes. Virtual scrolling renders only the nodes visible in the viewport, typically 20–50 items, and recycles them as the user scrolls. The Angular CDK provides this out of the box.

// Install: npm install @angular/cdk
// app.module.ts or standalone imports:
import { ScrollingModule } from '@angular/cdk/scrolling';

// Template:
<cdk-virtual-scroll-viewport itemSize="56" style="height: 400px">
  <div *cdkVirtualFor="let invoice of invoices"
       class="invoice-row">
    {{ invoice.vendor_name }} — {{ invoice.total | currency }}
  </div>
</cdk-virtual-scroll-viewport>

itemSize is the fixed height of each row in pixels. For variable-height items, use AutoSizeVirtualScrollStrategy from the experimental package — it measures items and adjusts, though it's slightly more expensive to initialise.

4. trackBy / track in @for — prevent DOM thrashing

4

Without identity tracking, Angular destroys and recreates every DOM node when a list is re-assigned, even if only one item changed. trackBy tells Angular how to identify items across re-renders, so only genuinely changed or new items touch the DOM.

In the legacy *ngFor syntax:

<div *ngFor="let invoice of invoices; trackBy: trackById">
  {{ invoice.vendor_name }}
</div>
trackById(index: number, invoice: Invoice): string {
  return invoice.id;
}

In Angular 17's new @for control flow (preferred for new code), track is mandatory — the compiler enforces it:

@for (invoice of invoices; track invoice.id) {
  <app-invoice-row [invoice]="invoice" />
}

Use a stable, unique property like a database ID or UUID. Using index as the track key is better than nothing but still causes unnecessary re-renders when items are reordered or inserted in the middle.

5. Lazy-loaded standalone components

5

Module-based lazy loading is well known, but with standalone components in Angular 17 you can lazy-load individual components directly from a route — no NgModule wrapper required. The component and its entire dependency tree are split into a separate chunk and fetched only when the route is first navigated to.

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./dashboard/dashboard.component')
        .then(m => m.DashboardComponent),
  },
  {
    path: 'invoices',
    loadComponent: () =>
      import('./invoices/invoice-list.component')
        .then(m => m.InvoiceListComponent),
  },
];

Combine this with PreloadAllModules or a custom preloading strategy to pre-fetch chunks after the initial route has loaded, giving you the best of both worlds: fast initial load and near-instant subsequent navigation.

// main.ts or app.config.ts
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

provideRouter(routes, withPreloading(PreloadAllModules))

6. Measure first — profile before you optimise

Applying optimisations blindly wastes time and can introduce bugs. Before touching change detection or lazy loading, measure where the actual bottleneck is. Two tools I always use:

Angular DevTools (Chrome extension) gives you a component tree with render times. Sort by time spent and you'll find the real offenders within minutes — usually one or two components responsible for 80% of the render cost.

Bundle analyser: run ng build --stats-json then feed the output to webpack-bundle-analyzer. You'll immediately see which third-party libraries are bloating your initial bundle. I've seen projects shave 200KB off their initial load just by switching from moment.js to date-fns or by moving a charting library behind a lazy route.

# Build with stats
ng build --stats-json

# Analyse the bundle
npx webpack-bundle-analyzer dist/your-app/stats.json

Target a First Contentful Paint under 1.8s and a Total Blocking Time under 200ms on a mid-range mobile device. If you're above those, the five tips above — especially OnPush, signals, and lazy loading — will move the needle.

7. Three more wins worth knowing

These don't make my "top 5" list because they're more situational, but they're worth keeping in mind for larger apps:

// ❌ Method call in template — runs every cycle
{{ formatDate(item.createdAt) }}

// ✅ Pure pipe — cached, runs only when input changes
{{ item.createdAt | formatDate }}

// ✅ async pipe with OnPush
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div *ngIf="data$ | async as data">{{ data.name }}</div>`
})
export class MyComponent {
  data$ = this.service.getData();
}

Angular 17 performance checklist

A quick reference I run through before shipping any Angular feature to production:

These tips cover the most impactful Angular performance wins I've found across enterprise projects. If you're hitting performance walls on a large Angular app, get in touch — I'm available for contracts.