Skip to content
arrow_back Back to writings
10 MIN READ · 496 words
Listen to Article
click play to listen

Optimizing Large-Scale Angular Applications for Web Vitals

A comprehensive guide on performance tuning large enterprise Angular systems. We cover zone pollution, custom change detection strategies, and dynamic code chunking.

ANGULAR PERFORMANCE WEB VITALS

Achieving sub-second First Contentful Paint (FCP) and a low Cumulative Layout Shift (CLS) in large-scale Angular applications requires moving beyond default configurations. When an application grows to hundreds of modules and routes, Change Detection cycles and bundle sizes can degrade user experience and search engine rankings.

In this guide, we will analyze the three core pillars of optimizing Angular applications to pass Core Web Vitals audits.

1. Eliminating Zone.js Pollution

By default, Angular uses Zone.js to intercept asynchronous events (like HTTP requests, timers, and click handlers) and automatically trigger Change Detection globally. While convenient, this model often leads to unnecessary rendering cycles.

Running Outside the Angular Zone

For operations that do not update the UI—such as scroll events, WebSockets, or third-party chart initializations—you must execute them outside the Angular zone using NgZone.runOutsideAngular.

import { Component, OnInit, NgZone, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-performance-chart',
  template: `<div #chartContainer></div>`
})
export class ChartComponent implements OnInit {
  @ViewChild('chartContainer', { static: true }) chartContainer!: ElementRef;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      // Initialize heavy third-party D3 or canvas rendering
      initializeHeavyChart(this.chartContainer.nativeElement);
    });
  }
}

By executing this outside the zone, you prevent Angular from running change detection across the entire component tree on every animation frame or mouse move.


2. Transitioning to OnPush Change Detection

The default change detection strategy checks every component from top to bottom on every event. By switching to ChangeDetectionStrategy.OnPush, you instruct Angular to only check a component when:

  1. One of its @Input() properties receives a new reference.
  2. An event handler inside the component fires.
  3. An observable bound in the template via the async pipe emits a new value.
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-user-row',
  template: `<div>{{ user.name }} - {{ user.role }}</div>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserRowComponent {
  @Input() user!: User;
}

Using OnPush turns change detection from a $O(N)$ global tree traversal into a highly targeted $O(\log N)$ branch validation.


3. Dynamic Code Chunking & Preloading

Large JavaScript bundles are the primary culprit behind poor Interaction to Next Paint (INP) and Total Blocking Time (TBT).

Route-Level Lazy Loading

Ensure that all feature modules are lazy-loaded using the modern Router API:

const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
  }
];

Preloading Strategies

To prevent delays when a user clicks a lazy-loaded route, use a custom preloading strategy that preloads modules in the background after the initial page loads:

import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    return route.data && route.data['preload'] ? load() : of(null);
  }
}

Implementing these practices will keep your bundle payloads low and change detection cycles optimal, ensuring your Angular apps maintain a perfect 100/100 performance score.