IAnswerable

Fix Core Web Vitals Without a Rewrite

15 Jan 2026

Core Web Vitals feel like a grading system designed by someone who’s never shipped a deadline. You don’t need to rewrite your app to move the numbers. Most sites have one or two specific mistakes dragging each metric down, and each has a targeted fix.

LCP: find the actual element first

Largest Contentful Paint is almost never “the whole page is slow” — it’s one specific element (usually a hero image, sometimes a big text block behind a slow font) that renders late. Find it before you optimize anything:

Open Chrome DevTools → Performance panel → record a load → look for the “LCP” marker in the timeline. It names the exact element.

Common causes and fixes:

Cause Fix
Hero image loads after JS bundle Use a plain <img> or next/image with priority, not a component that mounts after hydration
Image isn’t preloaded Add <link rel="preload" as="image" href="..."> for the specific LCP image
Render-blocking CSS/JS Defer non-critical CSS, inline critical CSS for above-the-fold content
Slow server response (TTFB eats into the LCP budget) Cache the route, or check for a slow DB query in the initial render path

The priority prop matters specifically for next/image — without it, the image lazy-loads by default, which is exactly wrong for the one image that determines your LCP score:

<Image src="/hero.jpg" alt="" width={1200} height={600} priority />

CLS: reserve the space before content arrives

Cumulative Layout Shift is caused by things that change size after they’ve already rendered, pushing everything below them down. The fix is almost always the same shape: reserve the space up front.

img {
  aspect-ratio: attr(width) / attr(height);
}

INP: profile the actual interaction, not the whole page

Interaction to Next Paint replaced FID in 2024, and it measures the full cost of an interaction — not just when the event handler starts, but until the next paint after it finishes. Slow INP usually means the handler itself is doing too much work synchronously.

// Bad: filters 10,000 items synchronously on every keystroke
onChange={(e) => setResults(bigList.filter(x => x.includes(e.target.value)))}

// Better: debounce the expensive work
const debouncedFilter = useMemo(() => debounce(filterFn, 150), [])

Measure with real data, not just Lighthouse

Lighthouse runs in a lab, on one machine, once. Your actual Web Vitals score is judged on real user data (CrUX, or your own RUM). A page that scores well in Lighthouse can still fail in the field if your real users are on slower connections or older phones than your test machine. If you can, wire up the web-vitals library to send real metrics to your analytics:

import { onLCP, onCLS, onINP } from 'web-vitals'
onLCP(console.log)
onCLS(console.log)
onINP(console.log)

The takeaway

Don’t optimize blind. Identify the specific LCP element, the specific layout-shifting element, and the specific slow interaction — then fix that one thing. Three targeted fixes usually move all three scores more than a broad “make everything faster” pass.