Skip to main content

7 - Optimizing Core Web Vitals in Angular

Practical guide to optimizing LCP, INP, and CLS in Angular: NgOptimizedImage, bundle optimization, font loading, production monitoring, and benchmarks.

What do you think about this article?

At a Glance

Practical guide to optimizing LCP, INP, and CLS in Angular: NgOptimizedImage, bundle optimization, font loading, production monitoring, and benchmarks.

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
1Angular SignalsFine-grained reactivity
2Zoneless Change DetectionRemoving Zone.js
3New Template Syntax @if, @for, @deferModern control flow
4Standalone ComponentsModule-free architecture
5Signal FormsReactive forms with Signals
6SSR and Incremental HydrationServer-Side Rendering
7You are here → Core Web Vitals in AngularPerformance and metrics
8Angular PWAProgressive Web Apps
9Advanced Dependency InjectionTree-shakable DI
10Migrating from Angular 17 to 21Migration 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.

Using NgOptimizedImage to optimize LCP

// 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.

index.html - Preconnect and preload for critical resources

<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 NgOptimizedImage with priority on the LCP image
  • Enable SSR to send HTML with pre-rendered content
  • Add preconnect for image CDN and font domains
  • Use preload for 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.

INP optimization with Signals and OnPush

// 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.

Breaking long tasks with scheduler yield

// 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.

CLS prevention with explicit dimensions and aspect-ratio

<!-- 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.

Skeleton screen component to prevent CLS

// 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.

Lazy loading with @defer to reduce initial bundle

<!-- 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

Code splitting per route with lazy loading

// 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

Analyzing bundle contents

# 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.

angular.json - Optimized build configuration

{
  "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 native Intl.DateTimeFormat API
  • lodash (~70KB): Import individual functions with lodash-es/debounce or 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.

Integrating web-vitals in Angular

// 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

Responsive images with NgOptimizedImage and image loader

// 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

Font optimization: preload, display swap, subset

/* 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

Comprehensive monitoring with Performance Observer

// 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.

GA4 dashboard for Core Web Vitals

// 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: fetch requests can be canceled when the user navigates away. navigator.sendBeacon guarantees 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 NgOptimizedImage with priority, 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: swap with size-adjust
  • Bundle: Route-based code splitting + @defer reduce initial JS by 80%+
  • Images: WebP as default, AVIF for photos, srcset for responsive, automatic lazy loading
  • Fonts: Self-hosting, subsetting, font-display: optional for maximum performance
  • Measurement: web-vitals library + GA4 for RUM, Lighthouse for lab data
  • Monitoring: Performance Observer API, Long Task detection, sendBeacon for 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.

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.

Sources and further reading

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects