Back to Blog
Angular DevelopmentMarch 5, 202610 min read

Angular Performance Optimization: What Actually Moves the Needle

DR

Doug Ribeiro

10 min read

Most Angular performance articles are about OnPush change detection and trackBy. Those matter, but they are not where I start. Every Angular app I have profiled already had OnPush on some components and trackBy in some loops, and was still slow. The problems were elsewhere: an oversized initial bundle, subscription leaks keeping destroyed components alive, and long lists rendering hundreds of DOM nodes at once. OnPush was irrelevant until those were fixed. The code samples below use a loan-application domain purely as an illustration; none of them come from an employer's codebase.

Start with the Bundle

Run source-map-explorer on your main chunk before doing anything else. The output tells you, in bytes, what is eating your initial load. In every audit I have run, the biggest offenders are third-party libraries that got imported without thinking and feature code that ended up in the main bundle because someone imported a component directly instead of using loadComponent.

  • npx source-map-explorer dist/your-app/browser/main-*.js gives a visual breakdown of bundle contents (build with --source-map first, since production builds omit source maps by default)
  • Any library over 50KB in the main bundle that is not needed on the first screen is a candidate for moving
  • Date libraries, chart libraries, and PDF renderers are the most common offenders
  • Every feature route that uses loadComponent or loadChildren instead of a direct import is a chunk boundary

Converting eagerly imported feature routes to lazy loading is reliably the single biggest cut to the initial bundle, and the mobile score follows it. Everything else on this list is incremental by comparison.

OnPush Is Table Stakes, Not a Silver Bullet

ChangeDetectionStrategy.OnPush tells Angular to skip a component during change detection unless one of its inputs changes by reference, an event originates from it, or an async pipe or signal marks it dirty. In a component tree with hundreds of nodes, this dramatically reduces wasted work per event cycle.

typescript
import { CurrencyPipe } from '@angular/common';

@Component({
  // Attribute selector, not an element selector. <app-application-row> inside a <tbody> is
  // invalid HTML, and the browser hoists it out of the table before Angular gets a say.
  selector: 'tr[appApplicationRow]',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <td>{{ application.applicantName }}</td>
    <td>{{ application.requestedAmount | currency:'CAD' }}</td>
    <td [class]="'status-' + application.status">{{ application.status }}</td>
  `
})
export class ApplicationRowComponent {
  @Input({ required: true }) application!: LoanApplication;
}

The catch: OnPush requires immutable input patterns. If you mutate an array and pass the same reference, OnPush will not re-render. This bites developers who do array.push() and expect the view to update. Signals do not remove this rule. A signal only notifies when it receives a new value, compared with Object.is by default, so update(list => [...list, item]) re-renders and a push() on the same array does not.

Subscription Leaks Kill Performance Silently

Unmanaged subscriptions are the performance issue that never shows up in Lighthouse. A component subscribes to an interval or a WebSocket and never unsubscribes. The component is destroyed by navigation. The subscription keeps running. Its callback keeps doing work, it holds the destroyed component in memory, and in a Zone.js app every emission still triggers an application-wide change detection pass. Multiply that by a user who navigates the app for thirty minutes and you have dozens of active subscriptions on destroyed components.

On any codebase running Angular 16 or later, takeUntilDestroyed goes into every feature I ship. After chasing leaks of this kind in more than one codebase, it is the first thing I look for when reviewing a subscription.

typescript
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

@Component({ standalone: true })
export class ApplicationStatusComponent implements OnInit {
  private statusService = inject(ApplicationStatusService);
  private destroyRef = inject(DestroyRef);

  @Input({ required: true }) applicationId!: string;
  status = signal<ApplicationStatus | null>(null);

  ngOnInit(): void {
    // Polling every 10s for status updates
    this.statusService.pollStatus(this.applicationId).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(s => this.status.set(s));
  }
}

Virtual Scrolling for Large Lists

A 500-row table is not 500 DOM nodes. It is 500 rows plus every cell inside them, so three columns already puts you past 2000 elements. Each one carries styles, event listeners, and change detection overhead. CDK virtual scrolling renders only what fits in the viewport, which is the only sane approach for any list that can grow past 100 items.

typescript
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  standalone: true,
  imports: [ScrollingModule, ApplicationRowComponent],
  // The viewport has to be the scrolling container, so the table lives inside it. Set
  // table-layout: fixed and a row height that matches itemSize, or the recycling maths drifts.
  template: `
    <cdk-virtual-scroll-viewport itemSize="64" style="height: 70vh">
      <table class="application-table">
        <tbody>
          <tr appApplicationRow
            *cdkVirtualFor="let app of applications(); trackBy: trackById"
            [application]="app"
          ></tr>
        </tbody>
      </table>
    </cdk-virtual-scroll-viewport>
  `
})
export class ApplicationListComponent {
  applications = input.required<LoanApplication[]>();
  trackById = (_: number, app: LoanApplication) => app.id;
}

Pure Pipes Over Template Methods

A method call in a template runs on every change detection cycle. A pure pipe runs only when its inputs change. This is a code review flag I raise every time I see it.

typescript
// In the template. Bad: runs on every CD cycle
// <td>{{ formatAmount(application.requestedAmount) }}</td>

// Good: runs only when requestedAmount changes
// <td>{{ application.requestedAmount | loanAmount }}</td>

@Pipe({ name: 'loanAmount', standalone: true, pure: true })
export class LoanAmountPipe implements PipeTransform {
  transform(value: number): string {
    return new Intl.NumberFormat('en-CA', {
      style: 'currency',
      currency: 'CAD',
      maximumFractionDigits: 0
    }).format(value);
  }
}

The Order That Actually Works

  • 1. Audit the bundle. Cut anything large that is not needed on first render.
  • 2. Apply lazy loading to all feature routes. This is the highest-impact change per hour of work.
  • 3. Find subscription leaks. Add takeUntilDestroyed across the codebase as a standard.
  • 4. Add OnPush to all components. Fix input immutability violations as they surface.
  • 5. Add virtual scrolling to any list that can grow past 100 items.
  • 6. Replace template method calls with pure pipes.
Bundle size reductionsource-map-explorer, tree shaking, import audit 1 Lazy loadingroute-level code splitting, preloading strategies 2 Subscription leakstakeUntilDestroyed, async pipe, memory profiler 3 OnPush + Signalseliminate unnecessary change detection cycles 4 Virtual scrollCDK VirtualScrollViewport for large lists 5 Pure pipesmemoized transforms, avoid template methods 6

The fastest code is the code that does not run. Every optimization on this list works by deferring or eliminating work Angular was doing unnecessarily.

None of this requires an architectural overhaul. All of it can be applied incrementally, feature by feature, sprint by sprint. The bundle analysis alone tells you where to start. After that it is just execution.

References

AngularPerformanceTypeScriptFrontend