Post 09
Core Web Vitals: A Field Guide for Real Products
Lab scores lie. Your Lighthouse 98 means little if real users on real phones are seeing an LCP of four seconds. Here is how I actually diagnose and fix INP, LCP, and CLS on shipping products — with the code and the CI wiring.

There are two kinds of performance data and they disagree constantly. Lab data — Lighthouse, WebPageTest — runs one synthetic load on a controlled connection. Field data — the Chrome UX Report, and any real-user monitoring you have — is what your actual users experienced over the last 28 days. When they disagree, field data wins. Always fix for the field.
Measure the field yourself
CrUX is a 28-day trailing average and only covers Chrome traffic with enough volume. Add your own RUM so you can see a regression in days, not weeks, and slice it by route and device.
1"use client";2 3import { useReportWebVitals } from "next/web-vitals";4 5export function WebVitals() {6 useReportWebVitals((metric) => {7 const body = JSON.stringify({8 name: metric.name, // LCP | INP | CLS | FCP | TTFB9 value: metric.value,10 rating: metric.rating, // good | needs-improvement | poor11 id: metric.id,12 path: window.location.pathname,13 });14 // sendBeacon survives the page unload15 navigator.sendBeacon("/api/rum", body);16 });17 return null;18}LCP: what is the biggest thing, and why is it late
Largest Contentful Paint is almost always a hero image, a heading, or a background. The fix is a sequence of questions:
- Is the LCP element discoverable in the initial HTML? If it is rendered by client JS, the browser cannot start loading it until the bundle parses. Move it to the server.
- Is it preloaded? For a hero image,
fetchpriority="high"gets it in flight immediately. - Is it the right size? A 3000px image displayed at 800px wastes most of its bytes on the critical path.
- Is a font blocking it? If LCP is text, a slow web font with
font-display: blockholds the paint.
1import Image from "next/image";2 3<Image4 src="/hero.jpg"5 alt=""6 fill7 priority // adds <link rel=preload> + fetchpriority=high8 sizes="100vw"9 quality={70}10 className="object-cover"11/>12 13// if it is a CSS background instead, preload it by hand:14// <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />1import { Inter } from "next/font/google";2 3const inter = Inter({4 subsets: ["latin"],5 display: "swap", // show fallback immediately, swap when ready6 adjustFontFallback: true, // matches metrics to cut the swap shift7});INP: the metric that replaced FID, and it is harder
Interaction to Next Paint measures the lag between a user action and the screen updating — across the whole session, at the 98th percentile. It punishes long tasks on the main thread. This is where heavy client components hurt.
1"use client";2import { useState, useDeferredValue, useMemo } from "react";3 4export function ProductList({ all }: { all: Product[] }) {5 const [query, setQuery] = useState("");6 // the input stays responsive; filtering runs at lower priority7 const deferred = useDeferredValue(query);8 9 const results = useMemo(10 () => all.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),11 [all, deferred],12 );13 14 return (15 <>16 <input value={query} onChange={(e) => setQuery(e.target.value)} />17 <Results items={results} stale={query !== deferred} />18 </>19 );20}For genuinely expensive work — parsing, crypto, image processing — move it off the main thread entirely with a Web Worker. Comlink makes the worker feel like a normal async function call.
1async function processAll(items: Item[]) {2 for (let i = 0; i < items.length; i++) {3 doWork(items[i]);4 // let the browser paint / handle input every 50 items5 if (i % 50 === 0) await scheduler.yield?.() ?? new Promise(r => setTimeout(r));6 }7}CLS: reserve the space
Cumulative Layout Shift is the cheapest to fix and the most embarrassing to ship. Every shift has the same cause: something loaded and pushed content that was already there.
| Shift source | Fix |
|---|---|
| Images without dimensions | Always set width/height or use aspect-ratio |
| Web fonts (FOUT jump) | size-adjust on @font-face, or adjustFontFallback |
| Ads / embeds | Reserve a min-height container before they load |
| Injected banners (cookie, promo) | Render them in the initial HTML, or overlay instead of pushing |
| Async content above the fold | Skeleton with the same dimensions as the loaded state |
.chart-slot { aspect-ratio: 16 / 9; } /* not min-height: 0 */.avatar { aspect-ratio: 1; width: 2.5rem; }.embed { min-height: 480px; } /* known ad slot height */Set budgets, and put them in CI
A one-time fix regresses the next sprint. What holds the line is a budget the pipeline enforces.
1{2 "ci": {3 "collect": { "url": ["http://localhost:3000/", "http://localhost:3000/pricing"], "numberOfRuns": 3 },4 "assert": {5 "assertions": {6 "categories:performance": ["error", { "minScore": 0.9 }],7 "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],8 "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],9 "total-blocking-time": ["error", { "maxNumericValue": 200 }],10 "resource-summary:script:size": ["error", { "maxNumericValue": 170000 }]11 }12 }13 }14}Performance is not a project you finish. It is a constraint you keep, the same way you keep the build green.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.