Back to Blog

98 on Lighthouse, Slow for Real Users: Core Web Vitals

98 on Lighthouse, Slow for Real Users: Core Web Vitals cover image

A marketing site scored 98 on Lighthouse and users complained it was slow. The developer kept sending screenshots of the score. Meanwhile the real-user data showed a median Largest Contentful Paint of 4.6 seconds.

Lighthouse ran on a fast laptop, on a fast connection, with a cold cache and no third-party consent banner because it had not been accepted. Real users were on mid-range phones, on mobile data, and the consent script blocked rendering for two seconds before the hero image even started loading.

The lab score was not wrong. It was measuring a different situation from the one the users were in — and that gap is where most web performance work goes astray.

The Three Metrics, and What Each Actually Means

Largest Contentful Paint — when the biggest thing above the fold finishes rendering. Usually the hero image or a heading block. Target under 2.5 seconds. This is the "does it feel like it loaded" metric and the one most worth fixing first.

Interaction to Next Paint — how long from a tap or click until the screen visibly changes, measured across the whole visit rather than just the first interaction. Target under 200ms. This one replaced First Input Delay and is much harder to pass, because it catches sluggishness throughout the session rather than only at load.

Cumulative Layout Shift — how much content jumps around while loading. Target under 0.1. This is the metric users hate most viscerally, because it is what makes you tap the wrong button.

Google uses the 75th percentile of real users, not your laptop. So a quarter of your visitors can be worse than the number you are being judged on, and lab tools cannot tell you where that quarter is.

Field Data Beats Lab Data

The single most useful change: stop optimising for the score and start measuring actual visitors.

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

function report(metric) {
  navigator.sendBeacon("/api/vitals", JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,          // good | needs-improvement | poor
    id: metric.id,
    path: location.pathname,
    // The context that makes the number actionable:
    conn: navigator.connection?.effectiveType,
    device: navigator.userAgent,
  }));
}

onLCP(report);
onINP(report);
onCLS(report);

Segment by page, device and connection. What that reveals is almost always more specific than a global score — one template with a bad LCP, or a fine desktop experience and a poor mobile one. Averages hide both.

Lighthouse is still useful, but as a debugging tool after field data has told you where to look, not as the target.

Fixing LCP

Nearly every bad LCP has the same handful of causes.

The LCP image is lazy-loaded. The most common self-inflicted wound. Someone applied loading="lazy" to every image including the hero, so the browser deliberately delays the one thing the metric measures. The above-the-fold image should be eager and prioritised:

<img src="/hero.avif" alt="…" width="1200" height="600"
     fetchpriority="high" decoding="async">

It is discovered late. If the hero comes from CSS, or from JavaScript after hydration, the browser cannot start fetching until it has parsed all of that. Put it in the HTML, or preload it.

Render-blocking resources. Every synchronous stylesheet and script in the head delays first paint. Inline the critical CSS, defer the rest, and audit what is actually in the head.

Slow server response. If the HTML takes 900ms to arrive, LCP cannot beat that. This is a backend problem wearing a frontend costume, and it is often a missing index.

Fonts. A web font with the default swap behaviour delays text rendering. Use font-display: swap, preload the one font used above the fold, and subset it.

Fixing INP

INP is a main-thread problem. The browser cannot paint while JavaScript is running, so every long task is a moment where taps go unanswered.

Ship less JavaScript. The most effective fix, and the least popular. Check your bundle for a date library, an icon set imported wholesale, or a chart library loaded on a page with no chart. Code-split by route and lazy-load anything below the fold or behind an interaction.

Break up long tasks. Anything over 50ms blocks interaction. Yield between chunks of work:

async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    // Let the browser handle input every 50 items.
    if (i % 50 === 0) await new Promise(r => setTimeout(r, 0));
  }
}

Do not do heavy work in an event handler. Update the UI first, then do the expensive part. Users forgive slow; they do not forgive unresponsive.

Watch third-party scripts. Analytics, chat widgets, consent managers, tag managers. These regularly account for most of a site's main-thread time and nobody owns them. Load them with defer, delay them until after interaction, and audit them quarterly — most sites accumulate scripts nobody remembers adding.

Fixing CLS

The easiest of the three, and mostly a matter of reserving space.

Dimensions on every image and video. Width and height attributes, or an aspect-ratio in CSS. Without them the browser does not know how much room to leave and everything below jumps when the image arrives.

Reserve space for anything injected. Ads, embeds, banners, cookie notices. A container with a fixed minimum height costs nothing and prevents the shift.

Never insert content above existing content. A promo bar that appears at the top after load pushes the entire page down at the exact moment someone is about to tap.

Preload the fonts that cause reflow. Text rendering in a fallback and then swapping to a differently-sized web font shifts every line.

The Order I Would Work In

Given a slow site and limited time:

Measure real users first, and segment by device and page. Then check the server response time, because nothing on the client fixes a slow origin. Then find the LCP element and make sure it is discovered early and not lazy-loaded. Then audit the JavaScript bundle and the third-party scripts. Then add dimensions everywhere to kill layout shift. Then, last, look at Lighthouse for anything remaining.

That marketing site went from 4.6 seconds to about 1.9 with two changes: deferring the consent script until after first paint, and removing loading="lazy" from the hero image. The Lighthouse score did not move at all — it had been 98 the whole time.

Related Posts