Post 10

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.

Dec 9, 2025/13 min readPerformance
ShareY
Core Web Vitals: A Field Guide for Real Products

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.

app/web-vitals.tsx
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:

  1. 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.
  2. Is it preloaded? For a hero image, fetchpriority="high" gets it in flight immediately.
  3. Is it the right size? A 3000px image displayed at 800px wastes most of its bytes on the critical path.
  4. Is a font blocking it? If LCP is text, a slow web font with font-display: block holds the paint.
hero — get the LCP image right
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" />
app/layout.tsx — font that does not block text paint
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.

keep a heavy filter off the interaction path
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}
Tip

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.

yield to the browser inside a long loop
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 sourceFix
Images without dimensionsAlways set width/height or use aspect-ratio
Web fonts (FOUT jump)size-adjust on @font-face, or adjustFontFallback
Ads / embedsReserve 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 foldSkeleton with the same dimensions as the loaded state
reserve space for anything that streams in
.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.

lighthouserc.json
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.

All posts

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.