Optimizing Core Web Vitals in Angular
Core Web Vitals are the performance metrics that Google uses as a ranking factor to determine the quality of a website's user experience. For Angular applications, optimizing these metrics is not just a technical concern: it is a direct investment in search engine visibility and user satisfaction.
In this seventh article of the Modern Angular series, we will take a deep
dive into what Core Web Vitals are, how to measure them, and most importantly how to optimize
them in an Angular application with specific techniques for LCP, INP, and CLS. From image
handling with NgOptimizedImage to bundle reduction with @defer,
through to production monitoring with Real User Monitoring.
What You Will Learn
- What Core Web Vitals are and why they are a Google ranking factor
- How to optimize Largest Contentful Paint (LCP) with NgOptimizedImage and preconnect
- Strategies to improve Interaction to Next Paint (INP) with Signals and reduced change detection
- How to prevent Cumulative Layout Shift (CLS) with explicit dimensions and skeleton screens
- Angular-specific optimizations: SSR, lazy loading with
@defer, route-based code splitting - Bundle analysis and reduction with tree shaking, minification, and source-map-explorer
- Performance measurement with web-vitals, Lighthouse, and Chrome DevTools
- Advanced image and font optimization for the web
- Production monitoring with Performance Observer API and GA4
Modern Angular Series Overview
| # | Article | Focus |
|---|---|---|
| 1 | Angular Signals | Fine-grained reactivity |
| 2 | Zoneless Change Detection | Removing Zone.js |
| 3 | New Template Syntax @if, @for, @defer | Modern control flow |
| 4 | Standalone Components | Module-free architecture |
| 5 | Signal Forms | Reactive forms with Signals |
| 6 | SSR and Incremental Hydration | Server-Side Rendering |
| 7 | You are here → Core Web Vitals in Angular | Performance and metrics |
| 8 | Angular PWA | Progressive Web Apps |
| 9 | Advanced Dependency Injection | Tree-shakable DI |
| 10 | Migrating from Angular 17 to 21 | Migration guide |
1. What Are Core Web Vitals
Core Web Vitals are a set of metrics defined by Google that measure three fundamental aspects of user experience: loading speed, interaction responsiveness, and visual stability. Since 2021, they have been an official part of Google's ranking signals, meaning a site with poor Core Web Vitals is penalized in search results.
The Three Core Web Vitals Metrics
| Metric | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time to render the largest visible element | ≤ 2.5s | 2.5s - 4.0s | > 4.0s |
| INP (Interaction to Next Paint) | Latency between user interaction and visual update | ≤ 200ms | 200ms - 500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Unexpected shifts of elements on the page | ≤ 0.1 | 0.1 - 0.25 | > 0.25 |
From FID to INP: The Responsiveness Metric Evolution
In March 2024, Google replaced First Input Delay (FID) with Interaction to Next Paint (INP) as the official metric. The key difference is that FID only measured the delay of the first interaction, while INP measures the latency of all interactions throughout the entire session, returning the worst value (at the 98th percentile). This makes INP a much more representative metric of the actual user experience.
Why They Matter for Angular
Angular applications, especially Single Page Applications (SPA) with client-side rendering, have historically struggled with Core Web Vitals. The main reasons are:
- Heavy JavaScript bundles: Angular includes the framework, components, and dependencies in a single initial bundle
- Expensive change detection: Zone.js intercepts every async event and triggers change detection cycles
- Client-side rendering: Without SSR, the browser must download, parse, and execute all JS before showing content
- Suboptimal lazy loading: Without proper strategies, all code is loaded on first access
2. Optimizing Largest Contentful Paint (LCP)
LCP measures the time it takes to render the largest visible element in the initial viewport. Typically this is a hero image, a block of text, or a video element. For Angular, LCP is heavily influenced by Server-Side Rendering, image loading, and render-blocking resources.
NgOptimizedImage: The Essential Directive
Angular provides the NgOptimizedImage directive in the
@angular/common package that automatically applies best practices for image
loading. This directive handles lazy loading, priority, dimensions, and modern formats.
// hero.component.ts
import { Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
@Component({
selector: 'app-hero',
standalone: true,
imports: [NgOptimizedImage],
template: `
<section class="hero">
<!-- LCP image: priority loads immediately -->
<img
ngSrc="assets/images/hero-banner.webp"
width="1200"
height="630"
priority
alt="Portfolio hero banner"
/>
<!-- Below-the-fold images: automatic lazy loading -->
<img
ngSrc="assets/images/project-thumb.webp"
width="400"
height="300"
alt="Project preview"
/>
</section>
`
})
export class HeroComponent {}
Common Mistake: Forgetting the priority Attribute
Without priority, NgOptimizedImage automatically applies
loading="lazy" to all images. For the LCP image (typically the hero
image above the fold), this is counterproductive because it delays
loading the most important element. Always add priority to your LCP image.
Preconnect and Preload for Critical Resources
The browser must resolve DNS, establish TCP connections, and negotiate TLS before downloading
resources from external domains. With preconnect and preload we
can anticipate these operations.
<head>
<!-- Preconnect to critical resource domains -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://cdn.example.com" />
<!-- Preload the LCP image -->
<link
rel="preload"
as="image"
href="/assets/images/hero-banner.webp"
type="image/webp"
fetchpriority="high"
/>
<!-- Preload the critical font -->
<link
rel="preload"
as="font"
href="/assets/fonts/inter-v12-latin-regular.woff2"
type="font/woff2"
crossorigin
/>
</head>
LCP Checklist for Angular
- Use
NgOptimizedImagewithpriorityon the LCP image - Enable SSR to send HTML with pre-rendered content
- Add
preconnectfor image CDN and font domains - Use
preloadfor the hero image and critical font - Convert images to WebP or AVIF to reduce file size
- Implement a CDN image loader for automatic resizing
- Avoid redirects on critical resources (each redirect adds 300-500ms)
- Minimize critical CSS and inline it in the HTML
3. Optimizing Interaction to Next Paint (INP)
INP measures the time between a user interaction (click, tap, keypress) and the next visual update on the screen. For Angular, this metric is directly influenced by change detection, event handlers, and the use of Zone.js.
Signals to Reduce Change Detection
One of the most impactful optimizations for INP in Angular is the switch from Zone.js to Signals. With Signals, Angular updates only the components whose data has actually changed, eliminating global change detection cycles.
// product-list.component.ts - BEFORE (Zone.js, global change detection)
@Component({
selector: 'app-product-list',
template: `
<div *ngFor="let product of products">
<span>{{ product.name }}</span>
<button (click)="addToCart(product)">Add</button>
</div>
`
})
export class ProductListComponent {
products: Product[] = [];
addToCart(product: Product): void {
// Every click triggers change detection across the ENTIRE app
this.cartService.add(product);
}
}
// product-list.component.ts - AFTER (Signals, targeted updates)
@Component({
selector: 'app-product-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (product of products(); track product.id) {
<div>
<span>{{ product.name }}</span>
<button (click)="addToCart(product)">Add</button>
</div>
}
`
})
export class ProductListComponent {
products = input.required<Product[]>();
private cartService = inject(CartService);
addToCart(product: Product): void {
// Only this component gets updated
this.cartService.add(product);
}
}
Avoiding Long Tasks in Event Handlers
An event handler that performs heavy computations blocks the main thread and worsens INP.
The strategy is to break work into micro-tasks using requestAnimationFrame
or setTimeout.
// Optimized approach: break heavy work into chunks
export class DataProcessorComponent {
async onFilterChange(filter: string): Promise<void> {
// 1. Update UI immediately (visual feedback)
this.isLoading.set(true);
// 2. Yield control to the browser for paint
await this.yieldToMain();
// 3. Process heavy work in chunks
const results = await this.processInChunks(this.rawData(), filter);
// 4. Update results
this.filteredData.set(results);
this.isLoading.set(false);
}
private yieldToMain(): Promise<void> {
return new Promise(resolve => {
// scheduler.yield() where available, otherwise setTimeout
if ('scheduler' in window && 'yield' in (window as any).scheduler) {
(window as any).scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
private async processInChunks(
data: Item[],
filter: string,
chunkSize = 500
): Promise<Item[]> {
const results: Item[] = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
results.push(...chunk.filter(item => item.name.includes(filter)));
// Yield main thread between chunks
if (i + chunkSize < data.length) {
await this.yieldToMain();
}
}
return results;
}
}
Zone.js Impact on INP
| Scenario | With Zone.js | Zoneless (Signals) | Improvement |
|---|---|---|---|
| Button click in 100-item list | 85ms | 12ms | -86% |
| Typing in search field | 120ms | 18ms | -85% |
| Filter toggle in 1000-row table | 250ms | 45ms | -82% |
| Tab navigation | 65ms | 8ms | -88% |
4. Preventing Cumulative Layout Shift (CLS)
CLS measures the sum of unexpected element shifts on the page during its lifecycle. A high CLS creates a frustrating experience: the user is about to click a button and the layout shifts, causing them to click something else.
Explicit Dimensions for Images and Media
The most common cause of CLS is images and iframes without explicit dimensions. When the browser does not know an image's dimensions, it allocates zero space until loading, then the layout recalculates abruptly.
<!-- BAD: no dimensions, causes CLS -->
<img src="photo.jpg" alt="Photo" />
<!-- GOOD: explicit dimensions, zero CLS -->
<img src="photo.jpg" alt="Photo" width="800" height="600" />
<!-- BETTER: NgOptimizedImage with fill for responsive images -->
<div class="image-container" style="position: relative; aspect-ratio: 16/9;">
<img ngSrc="photo.webp" fill alt="Responsive photo" />
</div>
<!-- Videos and iframes: same rule -->
<iframe
src="https://www.youtube.com/embed/xxx"
width="560"
height="315"
loading="lazy"
title="Video tutorial"
></iframe>
Skeleton Screens for Dynamic Content
When content is loaded asynchronously (from APIs, databases, etc.), a skeleton screen preserves the needed space and prevents layout shift.
// article-skeleton.component.ts
@Component({
selector: 'app-article-skeleton',
standalone: true,
template: `
<div class="skeleton-wrapper">
<div class="skeleton-image" style="aspect-ratio: 16/9;"></div>
<div class="skeleton-title" style="height: 32px; width: 80%;"></div>
<div class="skeleton-text" style="height: 16px; width: 100%;"></div>
<div class="skeleton-text" style="height: 16px; width: 95%;"></div>
<div class="skeleton-text" style="height: 16px; width: 60%;"></div>
</div>
`,
styles: [`
.skeleton-wrapper {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.skeleton-image, .skeleton-title, .skeleton-text {
background: linear-gradient(90deg, #21262d 25%, #30363d 50%, #21262d 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`]
})
export class ArticleSkeletonComponent {}
// Usage in parent component
@Component({
template: `
@if (article(); as art) {
<app-article [data]="art" />
} @else {
<app-article-skeleton />
}
`
})
export class ArticlePageComponent {
article = signal<Article | null>(null);
}
Common CLS Causes and Solutions
| Cause | CLS Impact | Solution |
|---|---|---|
| Images without dimensions | 0.1 - 0.5 | width/height attributes or CSS aspect-ratio |
| Web font FOUT/FOIT | 0.05 - 0.15 | font-display: swap + size-adjust |
| Dynamically injected content | 0.1 - 0.3 | Skeleton screen or reserved min-height |
| Cookie banners/notifications | 0.05 - 0.2 | Overlay with position fixed, do not push content |
| Ads and third-party embeds | 0.1 - 0.4 | Container with reserved dimensions |
5. Angular-Specific Optimizations
Angular offers several built-in features that significantly improve Core Web Vitals. The
combination of SSR, lazy loading with @defer, and route-based code splitting
creates an architecture that loads only the necessary code, when it is needed.
SSR for Optimal LCP
As we saw in the previous article, Server-Side Rendering sends pre-rendered HTML to the browser, eliminating the wait time for JavaScript download and execution. This has a direct and measurable impact on LCP.
Lazy Loading with @defer
The @defer block allows loading components on demand, reducing the initial
JavaScript bundle and improving both LCP and INP.
<!-- Above-the-fold content: loaded normally -->
<app-hero />
<app-featured-projects />
<!-- Below-the-fold: loaded when visible -->
@defer (on viewport) {
<app-blog-preview />
} @placeholder {
<app-blog-skeleton />
}
@defer (on viewport) {
<app-testimonials />
} @placeholder {
<div style="min-height: 400px;"></div>
}
<!-- Heavy components: loaded on interaction -->
@defer (on interaction) {
<app-contact-form />
} @placeholder {
<button class="cta-button">Contact Me</button>
}
<!-- Non-critical components: loaded when browser is idle -->
@defer (on idle) {
<app-newsletter-signup />
}
Route-Based Code Splitting
// app.routes.ts - Automatic code splitting per route
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/home/home-page').then(m => m.HomePageComponent),
},
{
path: 'blog',
loadComponent: () =>
import('./pages/blog/blog-page').then(m => m.BlogPageComponent),
},
{
path: 'blog/:slug',
loadComponent: () =>
import('./pages/article/article-page').then(m => m.ArticlePageComponent),
},
{
path: 'dev-tools',
loadChildren: () =>
import('./pages/dev-tools/dev-tools.routes').then(m => m.DEV_TOOLS_ROUTES),
},
];
Code Splitting Impact on Metrics
| Strategy | Initial Bundle | LCP | TTI |
|---|---|---|---|
| No code splitting | 450KB | 3.2s | 4.1s |
| Route-based splitting | 180KB | 1.8s | 2.4s |
| Route + @defer splitting | 85KB | 0.9s | 1.3s |
| Route + @defer + SSR | 85KB | 0.6s | 1.1s |
6. Bundle Optimization
The JavaScript bundle size is directly proportional to loading and parsing time. Reducing the bundle means improving all metrics: LCP (less JS to download), INP (less code to execute), and indirectly CLS (faster rendering).
Bundle Analysis with source-map-explorer
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Production build with source maps
ng build --source-map
# Analyze the main bundle
npx source-map-explorer dist/portfolio/browser/main-*.js
# Alternative: use webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/portfolio/browser/stats.json
Tree Shaking and Build Optimizations
Angular CLI automatically applies tree shaking, minification, and compression in production builds. However, there are additional optimizations you can configure.
{
"projects": {
"portfolio": {
"architect": {
"build": {
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "250kB",
"maximumError": "500kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true
}
}
}
}
}
}
}
Heavy Dependencies to Watch
Some common libraries add significant weight to the bundle. Always verify the impact
with source-map-explorer and consider lighter alternatives:
- moment.js (~300KB): Replace with
date-fns(~30KB tree-shaked) or nativeIntl.DateTimeFormatAPI - lodash (~70KB): Import individual functions with
lodash-es/debounceor use native methods - rxjs (included): Import only needed operators, never
import * from 'rxjs' - chart.js (~200KB): Register only needed components with
Chart.register() - @angular/material: Import only the modules/components actually used
7. Measuring Performance
You cannot optimize what you do not measure. There are several tools for measuring Core Web Vitals, each with specific advantages. The ideal strategy combines lab data (Lighthouse, DevTools) with real user data (RUM, CrUX).
The web-vitals Library
Google's web-vitals library is the simplest and most reliable way to measure
Core Web Vitals in the browser. It integrates easily into an Angular application.
// web-vitals.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class WebVitalsService {
private platformId = inject(PLATFORM_ID);
async measureVitals(): Promise<void> {
if (!isPlatformBrowser(this.platformId)) return;
const { onLCP, onINP, onCLS, onFCP, onTTFB } = await import('web-vitals');
onLCP((metric) => {
this.reportMetric('LCP', metric.value, metric.rating);
});
onINP((metric) => {
this.reportMetric('INP', metric.value, metric.rating);
});
onCLS((metric) => {
this.reportMetric('CLS', metric.value, metric.rating);
});
onFCP((metric) => {
this.reportMetric('FCP', metric.value, metric.rating);
});
onTTFB((metric) => {
this.reportMetric('TTFB', metric.value, metric.rating);
});
}
private reportMetric(
name: string,
value: number,
rating: 'good' | 'needs-improvement' | 'poor'
): void {
// Send to Google Analytics 4
if (typeof gtag !== 'undefined') {
gtag('event', name, {
value: Math.round(name === 'CLS' ? value * 1000 : value),
event_category: 'Web Vitals',
event_label: rating,
non_interaction: true,
});
}
// Log for debug in development
console.log(`[${rating.toUpperCase()}] ${name}: ${value.toFixed(2)}`);
}
}
Measurement Tools Comparison
| Tool | Type | Metrics | Use Case |
|---|---|---|---|
| Lighthouse | Lab data | LCP, CLS, TBT, FCP, SI | Auditing during development, CI/CD |
| Chrome DevTools Performance | Lab data | All + flame chart | Detailed long task debugging |
| PageSpeed Insights | Lab + Field | CWV + suggestions | Quick overview with CrUX data |
| web-vitals (library) | Field data (RUM) | LCP, INP, CLS, FCP, TTFB | Real user monitoring in production |
| Chrome UX Report (CrUX) | Field data | CWV (aggregated real data) | Real Chrome user data, used for ranking |
| Search Console | Field data | CWV per URL | SEO impact and problematic pages |
8. Advanced Image Optimization
Images typically represent 50-70% of a web page's total weight. Aggressive image optimization has the most significant impact on performance, especially on LCP and overall loading time.
Modern Formats: WebP and AVIF
Modern formats offer superior compression compared to JPEG and PNG without perceptible quality loss. AVIF in particular offers savings of 50% compared to JPEG.
Responsive Images with srcset
// app.config.ts - Configuring an image loader
import { provideImageKitLoader } from '@angular/common';
// Or use a custom loader
import { IMAGE_LOADER, ImageLoaderConfig } from '@angular/common';
export const appConfig: ApplicationConfig = {
providers: [
// Option 1: Built-in loader (Cloudinary, ImageKit, Imgix)
provideImageKitLoader('https://ik.imagekit.io/myaccount'),
// Option 2: Custom loader
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig) => {
const width = config.width ? `?w=${config.width}` : '';
const quality = '&q=80';
const format = '&fm=webp';
return `https://cdn.example.com/${config.src}${width}${quality}${format}`;
}
}
]
};
// Usage in template
@Component({
template: `
<!-- Automatically generates srcset with multiple sizes -->
<img
ngSrc="hero-banner.jpg"
width="1200"
height="630"
priority
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 80vw, 1200px"
alt="Hero banner"
/>
`
})
export class HeroComponent {}
Image Format Comparison
| Format | Compression vs JPEG | Browser Support | Recommended Use |
|---|---|---|---|
| JPEG | Baseline | 100% | Fallback for older browsers |
| WebP | -25-35% | 97%+ | Default for most use cases |
| AVIF | -50% | 92%+ | Photographs, complex images |
| PNG | +200-400% | 100% | Only when transparency is needed |
| SVG | N/A (vector) | 100% | Icons, logos, simple graphics |
9. Font Optimization
Web fonts are often a render-blocking resource: the browser does not show text until the font has been downloaded (FOIT - Flash of Invisible Text) or shows a fallback font that is then swapped causing a layout shift (FOUT - Flash of Unstyled Text). Both behaviors impact CLS and LCP.
Optimal Font Loading Strategy
/* styles.css - Optimized font declarations */
/* Font with Latin subset (reduces size by 60-70%) */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap; /* Show text immediately with fallback */
src: url('/assets/fonts/inter-v12-latin-regular.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
/* size-adjust reduces CLS caused by font swap */
size-adjust: 100%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/inter-v12-latin-700.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/jetbrains-mono-v13-latin-regular.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
/* Font stack with metrically compatible fallbacks */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
code, pre {
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code',
'SF Mono', Consolas, monospace;
}
font-display Strategies Compared
| Value | Behavior | CLS Impact | LCP Impact | Recommended |
|---|---|---|---|---|
swap |
Shows fallback immediately, swaps when ready | Medium (possible FOUT) | Low (text visible immediately) | Yes, with size-adjust |
block |
Hides text for 3s, then shows fallback | Low | High (FOIT, invisible text) | No |
fallback |
Hides 100ms, then fallback for 3s | Low-medium | Medium | Yes, for non-critical fonts |
optional |
Hides 100ms, uses only if already cached | None | None | Yes, maximum performance |
10. Production Monitoring
Lab data (Lighthouse) is useful during development, but does not represent the real user experience. Real User Monitoring (RUM) collects metrics from actual user browsers, providing representative data of real-world conditions: different devices, slow connections, varying geolocation.
Performance Observer API
// performance-monitor.service.ts
@Injectable({ providedIn: 'root' })
export class PerformanceMonitorService {
private platformId = inject(PLATFORM_ID);
constructor() {
afterNextRender(() => {
this.observeLCP();
this.observeCLS();
this.observeLongTasks();
});
}
private observeLCP(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1] as any;
const lcpValue = lastEntry.startTime;
const lcpElement = lastEntry.element?.tagName || 'unknown';
this.sendToAnalytics('LCP', {
value: Math.round(lcpValue),
element: lcpElement,
url: lastEntry.url || '',
rating: lcpValue <= 2500 ? 'good' : lcpValue <= 4000 ? 'needs-improvement' : 'poor'
});
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
private observeCLS(): void {
let clsScore = 0;
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsScore += entry.value;
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// Send CLS when user leaves the page
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.sendToAnalytics('CLS', {
value: Math.round(clsScore * 1000),
rating: clsScore <= 0.1 ? 'good' : clsScore <= 0.25 ? 'needs-improvement' : 'poor'
});
}
});
}
private observeLongTasks(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(
`Long Task detected: ${entry.duration.toFixed(0)}ms`,
entry
);
}
}
});
observer.observe({ type: 'longtask', buffered: true });
}
private sendToAnalytics(name: string, data: Record<string, any>): void {
// Send to GA4
if (typeof gtag !== 'undefined') {
gtag('event', name, {
...data,
event_category: 'Web Vitals',
non_interaction: true,
});
}
// Send to custom endpoint (beacon for reliability)
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify({
metric: name,
...data,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent,
}));
}
}
}
Google Analytics 4 Integration
GA4 natively supports Core Web Vitals through custom events. Combining the
web-vitals library with GA4, you can monitor real user performance and
create dashboards to identify problematic pages.
// Initialization in root component
@Component({
selector: 'app-root',
standalone: true,
template: `<router-outlet />`
})
export class AppComponent {
private webVitals = inject(WebVitalsService);
private platformId = inject(PLATFORM_ID);
constructor() {
afterNextRender(() => {
// Start Core Web Vitals measurement
this.webVitals.measureVitals();
// Monitor SPA navigation timing
this.trackNavigationTiming();
});
}
private trackNavigationTiming(): void {
const navigation = performance.getEntriesByType('navigation')[0] as any;
if (navigation) {
gtag('event', 'page_timing', {
dns_time: Math.round(navigation.domainLookupEnd - navigation.domainLookupStart),
tcp_time: Math.round(navigation.connectEnd - navigation.connectStart),
ttfb: Math.round(navigation.responseStart - navigation.requestStart),
dom_load: Math.round(navigation.domContentLoadedEventEnd - navigation.startTime),
full_load: Math.round(navigation.loadEventEnd - navigation.startTime),
});
}
}
}
Common Performance Monitoring Mistakes
- Relying solely on Lighthouse: Lab scores do not reflect real conditions. Always use RUM alongside Lighthouse
- Ignoring the 75th percentile: Google uses the 75th percentile (p75) of field data for ranking. The median is not enough
- Not segmenting data: Analyze metrics by device (mobile vs desktop), connection type, and geolocation
- Measuring only the homepage: Each page has different metrics. Content-heavy pages (blog, products) are often the most problematic
- Not using sendBeacon:
fetchrequests can be canceled when the user navigates away.navigator.sendBeaconguarantees delivery - Not monitoring Long Tasks: Long tasks > 50ms block the main thread and worsen INP. Monitor them to identify bottlenecks
Summary and Next Steps
Optimizing Core Web Vitals in Angular is not a one-time operation but a continuous process. Metrics change with every new feature, every new dependency, and every framework update. The key to success is combining continuous measurement (RUM in production) with proactive optimizations (SSR, lazy loading, image and font optimization).
Key Concepts from This Article
- Core Web Vitals: LCP (≤ 2.5s), INP (≤ 200ms), and CLS (≤ 0.1) are Google ranking factors
- LCP: Use
NgOptimizedImagewithpriority, SSR, preconnect, and modern formats (WebP/AVIF) - INP: Switch to Signals to eliminate global change detection, break long tasks with
scheduler.yield() - CLS: Explicit dimensions on images/media, skeleton screens,
font-display: swapwithsize-adjust - Bundle: Route-based code splitting +
@deferreduce initial JS by 80%+ - Images: WebP as default, AVIF for photos,
srcsetfor responsive, automatic lazy loading - Fonts: Self-hosting, subsetting,
font-display: optionalfor maximum performance - Measurement:
web-vitalslibrary + GA4 for RUM, Lighthouse for lab data - Monitoring: Performance Observer API, Long Task detection,
sendBeaconfor reliable delivery
Recommended Performance Targets for Angular
| Metric | Target | How to Achieve It |
|---|---|---|
| LCP | < 1.5s | SSR + NgOptimizedImage + preload hero image |
| INP | < 100ms | Zoneless + Signals + optimized event handlers |
| CLS | < 0.05 | Explicit dimensions + skeleton + font-display optional |
| Lighthouse Performance | 90+ | All optimizations combined |
| Initial bundle | < 100KB gzipped | Code splitting + tree shaking + @defer |
| TTFB | < 200ms | CDN + SSG for static pages + edge caching |
In the next article of the series, we will explore Angular PWA (Progressive Web Apps), analyzing how to transform an Angular application into an installable app with offline support, push notifications, and automatic updates via Service Worker.
Related deep dive: 8 - Angular + PWA: Creare un\.
Related deep dive: 9 - Dependency Injection Avanzata in Angular.
Frequently asked questions
What are Core Web Vitals?
Core Web Vitals are a set of metrics defined by Google that measure three fundamental aspects of user experience: loading speed, interaction responsiveness, and visual stability.
Why do Angular applications struggle with Core Web Vitals?
Angular applications, especially Single Page Applications (SPA) with client-side rendering, have historically struggled with Core Web Vitals due to Server-Side Rendering, image loading, and render-blocking resources.
What is the key difference between First Input Delay (FID) and Interaction to Next Paint (INP)?
The key difference is that FID only measured the delay of the first interaction, while INP measures the latency of all interactions throughout the entire session, returning the worst value at the 98th percentile.
What is a common cause of Cumulative Layout Shift (CLS) in Angular applications?
A common cause of CLS is images and iframes without explicit dimensions.
Commenti
Caricamento commenti...
Sign in to comment