Web Performance Secrets: Core Web Vitals from Red to Green

Your Lighthouse score is red. Users are bouncing. Google is penalizing your rankings. This guide fixes LCP, CLS, and INP with practical techniques — lazy loading, image optimization, font strategies, and real before-after audits.

Web Performance Secrets: Core Web Vitals from Red to Green illustration
On this page16 sections

Google uses Core Web Vitals as a ranking factor. A slow site does not just frustrate users — it literally pushes you down in search results. Yet most developers treat performance as an afterthought, adding a lazy loading directive and calling it done.

This guide takes you from red Lighthouse scores to green with concrete, measurable optimizations for each Core Web Vital.

The Three Core Web Vitals

Metric What It Measures Good Needs Improvement Poor
LCP (Largest Contentful Paint) Loading speed — when the biggest visible element renders ≤ 2.5s 2.5s - 4.0s > 4.0s
INP (Interaction to Next Paint) Responsiveness — delay between user interaction and visual response ≤ 200ms 200ms - 500ms > 500ms
CLS (Cumulative Layout Shift) Visual stability — how much the page layout shifts unexpectedly ≤ 0.1 0.1 - 0.25 > 0.25

Fixing LCP: Make the Biggest Element Load Fast

LCP measures when the largest visible element (usually a hero image, heading, or video poster) finishes rendering. The most common LCP killers:

1. Optimize Images

<!-- BEFORE: Unoptimized hero image -->
<img src="hero.png" alt="Hero">
<!-- 2.4MB PNG, no sizing, blocks rendering -->

<!-- AFTER: Optimized with modern formats -->
<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <img
    src="hero.jpg"
    alt="Hero"
    width="1200"
    height="600"
    loading="eager"
    fetchpriority="high"
    decoding="async"
  >
</picture>
<!-- 85KB AVIF, explicit dimensions, high priority -->

Key rules for LCP images:

  • Use AVIF/WebP: 50-90% smaller than PNG/JPEG with equal quality
  • Set explicit width/height: Prevents layout shift and helps the browser allocate space early
  • Use fetchpriority="high": Tells the browser to prioritize this image over others
  • Never lazy-load the LCP image: Use loading="eager" (or omit the attribute) for above-the-fold images

2. Preload Critical Resources

<!-- In <head>: preload the LCP image -->
<link rel="preload" as="image" href="hero.avif" type="image/avif">

<!-- Preload critical fonts -->
<link rel="preload" as="font" href="/fonts/inter.woff2"
      type="font/woff2" crossorigin>

<!-- Preconnect to third-party origins -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="dns-prefetch" href="https://analytics.example.com">

3. Eliminate Render-Blocking Resources

<!-- BEFORE: Render-blocking CSS and JS -->
<link rel="stylesheet" href="all-styles.css">  <!-- 250KB -->
<script src="analytics.js"></script>          <!-- Blocks parsing -->

<!-- AFTER: Critical CSS inline, rest deferred -->
<style>
  /* Only above-the-fold critical CSS (~14KB) */
  .hero { ... }
  .nav { ... }
</style>
<link rel="stylesheet" href="full-styles.css" media="print"
      onload="this.media='all'">
<script src="analytics.js" defer></script>

Fixing INP: Make Interactions Feel Instant

INP replaced FID (First Input Delay) in March 2024. It measures the delay for all interactions, not just the first one. Common INP killers:

1. Break Up Long Tasks

// BEFORE: One long task blocks the main thread for 400ms
function processLargeList(items) {
  items.forEach(item => {
    expensiveCalculation(item);  // Blocks UI for entire loop
  });
}

// AFTER: Yield to the browser between chunks
async function processLargeList(items) {
  const CHUNK_SIZE = 50;

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveCalculation(item));

    // Yield to browser: allow input handling and rendering
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

2. Debounce Expensive Event Handlers

// BEFORE: Recalculates on every scroll event (60+ times/second)
window.addEventListener('scroll', () => {
  recalculateLayout();  // 50ms each = constant jank
});

// AFTER: Throttle to once per frame
let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      recalculateLayout();
      ticking = false;
    });
    ticking = true;
  }
});

3. Use CSS contain for Complex Layouts

/* Tell the browser this element's layout is independent */
.card {
  contain: layout style paint;
  /* Browser skips recalculating this subtree when
     other parts of the page change */
}

/* For virtualized lists: contain size too */
.virtual-list-item {
  contain: strict;
  /* Browser can skip layout entirely for off-screen items */
}

Fixing CLS: Stop the Page from Jumping

1. Always Set Image Dimensions

<!-- BEFORE: No dimensions = layout shift when image loads -->
<img src="photo.jpg" alt="Photo">

<!-- AFTER: Browser reserves space before image loads -->
<img src="photo.jpg" alt="Photo" width="800" height="600">

<!-- Or use CSS aspect-ratio for responsive images -->
<style>
  .responsive-img {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
</style>

2. Font Loading Strategy

/* BEFORE: FOUT (Flash of Unstyled Text) causes layout shift */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  /* Default: font-display: auto (browser decides) */
}

/* AFTER: Swap with size-adjust to minimize shift */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
  size-adjust: 107%;           /* Match fallback font metrics */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

3. Reserve Space for Dynamic Content

<!-- BEFORE: Ad loads and pushes content down -->
<div id="ad-slot"></div>

<!-- AFTER: Pre-allocate space -->
<div id="ad-slot" style="min-height: 250px;"></div>

<!-- For skeleton loaders -->
<div class="skeleton" style="height: 200px; background: #e0e0e0;">
  <!-- Content loads here without shifting layout -->
</div>

Angular-Specific Optimizations

// 1. Use OnPush change detection
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  // Reduces unnecessary re-renders by 60-80%
})

// 2. Lazy load routes (already standard)
export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard.component')
  }
];

// 3. Use @defer for below-the-fold components
// In template:
// @defer (on viewport) {
//   <app-heavy-chart />
// } @placeholder {
//   <div class="skeleton" style="height: 400px"></div>
// }

// 4. Use NgOptimizedImage
import { NgOptimizedImage } from '@angular/common';

// <img ngSrc="hero.jpg" width="1200" height="600" priority />
// Automatically: sets fetchpriority, generates srcset,
// warns about missing dimensions, lazy loads by default

Measurement Tools

  • Lighthouse (Chrome DevTools): Lab data — synthetic tests on your machine
  • PageSpeed Insights: Combines lab data with real-user data from CrUX
  • Web Vitals JS library: Measure real user metrics in production
  • Chrome DevTools Performance tab: Flame chart showing exactly where time is spent
  • Search Console Core Web Vitals report: Aggregated field data from real users
// Measure real user Core Web Vitals
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(metric => sendToAnalytics('LCP', metric));
onINP(metric => sendToAnalytics('INP', metric));
onCLS(metric => sendToAnalytics('CLS', metric));

function sendToAnalytics(name, metric) {
  fetch('/api/vitals', {
    method: 'POST',
    body: JSON.stringify({
      name,
      value: metric.value,
      rating: metric.rating,  // 'good', 'needs-improvement', 'poor'
      url: location.href,
    }),
    keepalive: true,
  });
}

Key Takeaways

  • LCP: Optimize the hero image (AVIF/WebP, preload, fetchpriority), inline critical CSS, defer everything else
  • INP: Break long tasks into chunks with setTimeout(0), throttle event handlers, use CSS contain
  • CLS: Always set image dimensions, use font-display: swap with size-adjust, reserve space for dynamic content
  • Never lazy-load above-the-fold content — it makes LCP worse, not better
  • Use Angular @defer for below-the-fold components — it is built-in code splitting
  • Measure real users, not just Lighthouse — lab scores and field data often tell different stories
  • Performance is a feature — every 100ms of delay reduces conversions by 7%

Web performance is not about chasing a perfect Lighthouse score. It is about ensuring real users on real devices have a fast, stable, responsive experience. Fix the fundamentals in this guide, measure with real-user data, and iterate on what matters most to your users.

Share this article

Stuck on implementation?

Get private, 1-on-1 help with system design, performance, scaling, or any technical challenge.

Book a Session

Related Production Resources

Course

Free learning tracks

Turn this guide into a structured production engineering path.

Lab

Interactive engineering labs

Practice the same ideas through scenario-based simulators.

Reference

Production cheatsheets

Keep the operational commands and checks nearby.

Glossary

Key terms

Review the vocabulary behind the architecture.

Discussion

Questions, corrections, or production notes? Add them here so other learners can benefit.

Continue Reading

Related practical guides from the same production engineering path.

Frontend 12 min read

State Management Showdown: NgRx vs Signals vs Services in Angular

Angular has three state management approaches and choosing wrong means either over-engineering a todo app or under-engineering an enterprise dashboard. Compare NgRx, Signals, and plain services with real examples and a decision framework.

Angular NgRx
Frontend 12 min read

Web Authentication API: Passwordless Login with Passkeys

Passwords are the weakest link in security. Passkeys replace them with biometrics and device-bound credentials. Learn WebAuthn, FIDO2, and how to implement passwordless login in your web app with real code examples.

WebAuthn Passkeys