Someone runs Lighthouse, scores 98, and concludes performance is handled. Three months later Search Console reports the origin failing Core Web Vitals, and nobody can reconcile the two numbers.
Both are correct. They measure different things, and only one of them is about your actual users.
Lab and field are not the same measurement
| Lab (Lighthouse, PSI) | Field (CrUX, RUM) | |
|---|---|---|
| What it is | One simulated load, controlled conditions | Real loads by real users over 28 days |
| Device | An emulated mid-tier phone | Whatever people actually own |
| Network | Simulated throttling | Real networks, including rural mobile |
| Cache | Cold, always | Mixed, mostly warm |
| Interaction | None — nobody clicks anything | Real taps, scrolls and form entry |
| Statistic | A single run | 75th percentile across the distribution |
| Used for search | No | Yes |
The 75th percentile is the crux of the disagreement. It means one in four of your visits is worse than the reported number. Your laptop on office fibre is not in that quartile, and neither is a datacentre-hosted Lighthouse run.
LCP: the metric people fix in the wrong place
Largest Contentful Paint measures when the largest visible element in the viewport finishes rendering. Pass is 2.5 seconds at p75 on mobile.
Step one: find out what the LCP element actually is
Almost everybody assumes it is the hero image. Frequently it is a heading, a background image, or a block of text that renders after a web font loads.
// Log the real LCP element from a live page
new PerformanceObserver((list) => {
const e = list.getEntries().at(-1);
console.log('LCP element:', e.element);
console.log('LCP time:', Math.round(e.startTime), 'ms');
console.log('URL:', e.url || '(text node)');
}).observe({ type: 'largest-contentful-paint', buffered: true });
Run this on a real page on a real phone. Optimising the wrong element is the most common wasted effort in performance work.
Step two: break the time down
LCP decomposes into four parts, and the fix is entirely different for each:
- Time to first byte — server and network. This is a floor LCP can never beat.
- Resource load delay — the gap between TTFB and when the browser starts fetching the LCP resource.
- Resource load time — how long fetching it takes.
- Element render delay — the gap between the resource arriving and it appearing.
In practice load delay is the largest and most overlooked component. The image is not slow to download; the browser did not know it needed it until far too late, because it was discovered inside CSS, or after a JavaScript bundle executed, or it was lazy-loaded.
The fixes, ranked by observed impact
- Remove
loading="lazy"from the LCP element. Lazy-loading above the fold directly delays the metric it is measured by. This single mistake is astonishingly common and takes thirty seconds to fix. - Preload it with high priority:
<link rel="preload" as="image" href="/hero.avif" imagesrcset="/hero-400.avif 400w, /hero-800.avif 800w" imagesizes="100vw" fetchpriority="high"> - Serve it at the right size. A 2,400px-wide hero delivered to a 390px phone wastes about 95% of its bytes — and it is usually the LCP element, so the waste lands exactly where it hurts most.
- Eliminate render-blocking resources. Inline critical CSS; defer the rest. A blocking font stylesheet in
<head>delays every subsequent paint. - Fix TTFB with edge caching. If your server takes 900 ms to respond, 2.5 s LCP is arithmetically impossible.
INP: the metric that replaced FID and is much harder
Interaction to Next Paint measures the latency of interactions across the whole visit. Pass is 200 ms at p75.
INP is harder than FID was because FID only measured the delay before processing began on the first interaction — a metric almost everyone passed by accident. INP measures the full path from input to the next painted frame, for every interaction, and reports near the worst.
The cause is nearly always the main thread
Poor INP is long JavaScript tasks blocking rendering. The usual suspects, in order:
- Tag managers loading a decade of accumulated tags
- Chat widgets initialising at page load
- Consent management platforms doing work before anything else can proceed
- Analytics libraries with heavy initialisation
- Hydration of components nobody interacted with
Fixes
Yield to the main thread. Break long tasks into chunks so the browser can paint between them:
// Yield so pending input can be handled between chunks
async function yieldToMain() {
if ('scheduler' in window && 'yield' in scheduler) return scheduler.yield();
return new Promise((r) => setTimeout(r, 0));
}
async function processAll(items) {
for (const item of items) {
doWork(item);
if (navigator.scheduling?.isInputPending?.()) await yieldToMain();
}
}
Defer third parties until after first interaction. Most chat widgets do not need to exist until someone intends to chat.
Audit the tag manager. This is unglamorous and frequently the single largest win available. Every organisation has tags nobody can identify still firing on every page.
Give feedback before doing work. If an interaction triggers something genuinely expensive, paint the loading state first, then start the work on the next frame. INP measures time to next paint, not time to completion.
CLS: the cheapest one to fix
Cumulative Layout Shift measures unexpected movement. Pass is 0.1 at p75.
CLS has a short, well-understood cause list:
- Images and iframes without dimensions. Set
widthandheightattributes or CSSaspect-ratio. The browser reserves the space before the resource arrives. - Ads and embeds without reserved space. Give the slot a min-height matching the most common creative size.
- Web font metric shift. Use
font-display: optional, or match fallback metrics withsize-adjustandascent-override. - Content injected above existing content. Cookie banners, promotional bars and notification strips appearing after render push everything down. Reserve the space or overlay it.
Getting field data when you do not have enough traffic
CrUX requires a minimum sample. Below it, you get nothing at origin or URL level.
Options, in order of usefulness:
- Origin-level CrUX. Aggregated across your whole site. Coarse, but often available when URL-level is not.
- Your own RUM. The
web-vitalslibrary reports all three metrics from real sessions to any endpoint. This is the best answer for small sites and it is about twenty lines of code. - Lab data, labelled as such. Useful for direction. Not a measurement of your users, and it should never be reported as one.
import { onLCP, onINP, onCLS } from 'web-vitals';
const send = (m) => navigator.sendBeacon('/vitals',
JSON.stringify({ name: m.name, value: m.value, id: m.id, path: location.pathname }));
onLCP(send); onINP(send); onCLS(send);
What actually moves the number
An honest ranking, from experience across many sites:
- Fix the LCP element — correct size, correct format, not lazy-loaded, preloaded
- Delete third-party scripts nobody can justify
- Reserve space for everything that loads late
- Reduce TTFB with edge caching
- Break up long tasks and defer non-critical hydration
Notice that four of the five are about removing things. Performance work is mostly subtraction, and the hardest part is organisational rather than technical: getting agreement to delete a tag that somebody once asked for.