Optimizing Web App Performance with Core Web Vitals: Measuring and Improving LCP, INP, and CLS for Better Lighthouse Scores and SEO

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Last quarter, a client’s e-commerce site was sitting at a Lighthouse performance score of 41. The bounce rate was brutal. After three weeks of targeted Core Web Vitals work, the score hit 87 and organic traffic climbed 34%. No framework change, no rewrite — just systematic measurement and fixing the right things in the right order.

I’ve applied this playbook in production across multiple sites and the results have held up. What follows is the exact approach — including where most teams lose hours and what’s actually worth fixing first.

The Three Metrics That Actually Matter

Google’s Core Web Vitals report replaced the old FID with INP in March 2024. If you’re still optimizing for First Input Delay, you’re targeting a metric that no longer affects ranking signals. Here’s what the current field looks like:

  • LCP (Largest Contentful Paint) — How fast the biggest visible element (hero image, H1, etc.) renders. Target: under 2.5 seconds.
  • INP (Interaction to Next Paint) — How responsive the page feels across all user interactions during the session. Target: under 200ms.
  • CLS (Cumulative Layout Shift) — How much content jumps around after initial load. Target: under 0.1.

The catch: Lighthouse gives you lab data — a simulated environment. What Google uses for ranking is field data from real users, collected via the Chrome User Experience Report (CrUX). These two numbers often disagree, especially on INP.

Three Ways to Measure Core Web Vitals

Teams reach for different measurement tools and often pick the wrong one for the job. There are three main approaches, each with a specific purpose.

Option 1: Lighthouse / PageSpeed Insights (Lab Data)

Lighthouse runs in a controlled environment — throttled CPU, simulated 4G connection. Reproducible, fast, and good for catching obvious problems before deploying.

Pros: Instant feedback, local testing, CI/CD integration, detailed diagnostics per metric.

Cons: Doesn’t reflect real user conditions. INP is nearly impossible to measure in a lab — it requires actual user interactions. A Lighthouse INP of <200ms means nothing if users are clicking buttons during heavy background JavaScript execution.

Option 2: web-vitals.js (Real User Monitoring)

Google’s web-vitals library reports actual metric values from real sessions in the browser. This is field data — the same signal Google collects for CrUX.

Pros: Reflects actual user experience, catches device/network combinations your lab tests miss, works for INP properly.

Cons: Requires aggregation infrastructure (you need somewhere to send the data), takes time to accumulate statistically significant samples.

Option 3: CrUX Dashboard / Search Console

Google Search Console’s Core Web Vitals report shows 28-day aggregated field data from Chrome users. Free, no setup, but that 28-day lag means you won’t see the impact of a fix for weeks.

Pros: Direct signal for what affects ranking, zero implementation effort, URL-level breakdown.

Cons: Lagged, read-only, no drill-down into root causes.

Recommended Setup: Use All Three in the Right Order

After several production cycles, here’s what I keep coming back to: Lighthouse in CI to catch regressions, web-vitals.js in production to get real INP data, and Search Console to track ranking impact over time.

Start with web-vitals.js. Install it first so field data starts accumulating while you work on fixes:

npm install web-vitals

Then wire it up to your analytics or a simple logging endpoint:

import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToAnalytics({ name, value, rating, navigationType }) {
  const body = JSON.stringify({ metric: name, value, rating, navigationType });
  // Use sendBeacon so it doesn't block page unload
  navigator.sendBeacon('/api/vitals', body);
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

For CI integration, run Lighthouse headlessly with the lighthouse-ci package:

npm install -g @lhci/cli

# Run and assert thresholds
lhci autorun --upload.target=temporary-public-storage

Add a .lighthouserc.json to enforce budgets:

{
  "ci": {
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "interactive": ["warn", { "maxNumericValue": 3500 }]
      }
    }
  }
}

Implementation Guide: Fixing Each Metric

Fixing LCP

Your LCP element is almost always a hero image or the main heading. Two things drive the fastest improvements: getting the browser to discover the resource early, and making sure the resource isn’t oversized to begin with.

Add a preload hint for your LCP image in the HTML <head>:

<link rel="preload" as="image" href="/hero.webp"
      imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
      imagesizes="100vw">

If your LCP element loads via a CSS background-image, move it to an <img> tag — browsers can’t preload CSS backgrounds as efficiently. Set fetchpriority="high" on it:

<img src="/hero.webp" alt="Hero image" fetchpriority="high"
     width="1200" height="630" decoding="async">

Also check your Time to First Byte. LCP can’t be fast if the server is slow — target under 600ms. Quick sanity check with curl:

curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" https://yoursite.com

Fixing INP

Of the three metrics, INP takes the most work. It covers the entire session — not just initial load — so a slow click handler at any point during a visit counts against you. Long JavaScript tasks blocking the main thread during interactions are almost always the culprit.

Open Chrome DevTools → Performance tab → record while clicking around. Look for tasks over 50ms (marked in red). Those are your targets.

Break up long synchronous loops using scheduler.yield() (Chromium 115+) or the older setTimeout pattern:

// Old pattern — still works everywhere
async function processItems(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    // Yield to browser every 50 items
    if (i % 50 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

// Modern pattern — Chrome 115+
async function processItemsModern(items) {
  for (const item of items) {
    processItem(item);
    await scheduler.yield(); // Yields after every item if needed
  }
}

In React specifically, heavy re-renders are a common INP killer. Wrap expensive components with React.memo and push non-critical state updates into startTransition:

import { startTransition, useState } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleInput = (e) => {
    setQuery(e.target.value); // Urgent: update input immediately
    startTransition(() => {
      setResults(computeResults(e.target.value)); // Non-urgent: can defer
    });
  };

  return <input value={query} onChange={handleInput} />;
}

Fixing CLS

Most CLS problems trace back to three culprits: images without explicit dimensions, ads or embeds that expand after load, or web fonts triggering text reflow. Each one has a clear fix.

Always set explicit width and height on images. Modern browsers use those attributes to reserve space before the image loads:

<!-- Good: browser reserves space -->
<img src="photo.jpg" width="800" height="450" alt="..." loading="lazy">

<!-- Bad: browser doesn't know the height until image loads -->
<img src="photo.jpg" style="width:100%" alt="...">

For fonts, font-display: optional eliminates shifts entirely — at the cost of potentially skipping the custom font on slow connections. If you need the font to always appear, use font-display: swap with a tuned system font fallback to reduce the jump when it swaps in:

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap; /* Show fallback immediately, swap when ready */
  size-adjust: 98%; /* Tweak fallback metrics to reduce layout shift */
  ascent-override: 90%;
}

Dynamic content — banners, cookie notices — that appears above existing content needs its space reserved in CSS upfront. Don’t inject it and push everything down:

.cookie-banner-placeholder {
  min-height: 60px; /* Reserve space before banner loads */
  display: flex;
  align-items: center;
}

What to Fix First

When everything is scoring poorly, start with CLS. The fixes are mechanical — add dimensions, reserve space — and results show up fast. Then tackle LCP; it has the most direct impact on perceived load time and the most predictable solutions available. Leave INP for last. It requires the most profiling time and the fixes are almost always application-specific.

One thing worth pushing back on: don’t obsess over hitting exactly 100 on Lighthouse. Field data is what counts, and it reflects real devices — many of them mid-range Android phones running with 4x CPU throttling compared to your MacBook. I’ve seen sites at Lighthouse 95 with poor field data because nobody tested on actual mobile hardware. Check Search Console every few weeks and trust the trend, not a single score.

Share: