Core Web Vitals work goes wrong in a predictable way: someone runs Lighthouse, sees 62, spends a week optimising, gets 94, and the field data does not move. The score was never the target.
Measure first: field data vs lab data
Lab data (Lighthouse, PageSpeed Insights "Analyse") is a simulated load on throttled hardware. Reproducible, useful for comparing two builds — but it is not your users.
Field data (Chrome UX Report, the "Discover what your real users are experiencing" panel, or your own RUM) is aggregated from real Chrome users on real devices and networks. This is what Google uses for ranking, and it is the only data that matters for the business.
They diverge constantly. A site can score 95 in the lab and fail INP in the field, because the lab does not click anything and your users click a filter that re-renders three thousand DOM nodes.
Start with field data. If CrUX has insufficient traffic for your site, add RUM:
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}
onLCP(send); onINP(send); onCLS(send);
A week of your own data beats a year of guessing.
LCP: images, fonts and server response time
Largest Contentful Paint is usually the hero image or headline. Target under 2.5s at p75. It decomposes into four parts, and the fix depends entirely on which dominates:
Slow server response (TTFB). If TTFB is over ~800ms, nothing downstream saves you. Cache at the edge, avoid uncached database calls in the critical path, and be deliberate about force-dynamic — it opts a route out of all static optimisation, which is occasionally necessary and frequently accidental.
Render-blocking resources. Blocking CSS and synchronous scripts in <head> delay everything.
Resource load time. The LCP image itself. Use next/image with priority on the hero — it emits a preload and skips lazy loading:
<Image src={hero} alt="" priority sizes="100vw" />
priority on the LCP element is one of the highest-leverage single changes available. Equally, not setting sizes on a responsive image means the browser may download a 2000px file for a 400px slot.
Fonts. A web font that blocks text rendering delays LCP directly. next/font self-hosts, preloads and applies font-display: swap:
import { Lexend } from "next/font/google";
const lexend = Lexend({ subsets: ["latin"], display: "swap" });
INP: the new hard one
Interaction to Next Paint replaced FID in 2024 and is stricter. FID measured only the delay before processing began. INP measures the full path from interaction to the next rendered frame. Target under 200ms.
INP is a JavaScript problem. Common causes:
Long tasks blocking the main thread. Any task over 50ms delays every interaction. Usually hydration, a large state update, or an expensive render.
Re-rendering too much. A filter change that re-renders a whole list rather than the changed rows. Memoise the expensive children, virtualise long lists.
Doing work synchronously in the handler. Sorting a large array, or a heavy analytics payload assembled inline. Defer non-urgent work:
const [isPending, startTransition] = useTransition();
function onFilter(next) {
setInput(next); // urgent: keep the input responsive
startTransition(() => setFilters(next)); // non-urgent: the expensive re-render
}
Third-party scripts. Chat widgets, heat maps and tag managers all execute on the main thread. Load them with next/script at afterInteractive or lazyOnload, and audit whether each one earns its cost.
CLS killers
Cumulative Layout Shift should be under 0.1. Nearly all of it comes from four things:
Images without dimensions. Always give width and height, or fill with a sized container. next/image enforces this, which is one of its quieter benefits.
Ads, embeds and iframes. Reserve the space with a min-height before they load.
Late-loading fonts. A fallback metrically different from the web font reflows text on swap. next/font generates an adjusted fallback to minimise this.
Content injected above existing content. Cookie banners, promo bars and "you have items in your cart" notices that push the page down. Overlay them, or reserve their space.
Next.js specific wins
next/image— modern formats, correct sizing, no layout shift. Setpriorityon the LCP image andsizeson everything responsive.next/font— self-hosted, preloaded, adjusted fallbacks. Removes a render-blocking third-party request.next/dynamic— defer heavy components that are not visible initially (modals, editors, charts):const Chart = dynamic(() => import("./Chart"), { ssr: false });Server Components — the largest structural win available. Components that render on the server ship no JavaScript, which directly reduces hydration cost and helps INP. Keep
"use client"at the leaves, on the components that genuinely need interactivity, rather than at the top of a page.Route segment config — do not reach for
force-dynamicreflexively. Static and revalidated routes are dramatically faster.
Interpreting before and after numbers
When you measure, compare like with like: the same page type, the same p75 metric, the same device class, over a comparable window. A single Lighthouse run before and after proves very little — run-to-run variance in the lab is significant.
Look for a sustained shift in the field p75 over two to four weeks. That is slow and unglamorous, and it is the only signal that reflects reality. CrUX updates on a 28-day rolling window, so a change shipped today is not fully visible for a month.
Keeping it from regressing
Performance is not a project. It regresses the moment someone adds a marketing script.
Run Lighthouse CI in your pipeline with a budget that fails the build on regression
Keep a bundle-size check on pull requests
Continue collecting RUM and alert on p75 movement
Make a rule that new third-party scripts require a measured justification
The teams with good vitals are not the ones who did a performance sprint. They are the ones who made regressions visible.