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:
- One of its
@Input()properties receives a new reference. - An event handler inside the component fires.
- An observable bound in the template via the
asyncpipe 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.