Core Web Vitals get treated as an SEO checkbox. They're more useful than that: each one maps to a specific way an interface feels broken.
- LCP — how long until the page looks loaded
- INP — how long until the page responds to you
- CLS — how much things jump around while you're reading
Different causes, different fixes. Lumping them together is why "make the site faster" so often goes nowhere.
LCP: the largest element, painted
Largest Contentful Paint measures when the biggest above-the-fold element finishes rendering. Target: under 2.5 s at the 75th percentile.
First, find the element — don't guess:
new PerformanceObserver((list) => {
const entry = list.getEntries().at(-1);
console.log('LCP', entry.startTime.toFixed(0), entry.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });It's almost always a hero image, a headline blocked by a font, or a section waiting on data.
If it's an image, the fix is priority plus honest sizes:
<Image
src="/hero.png"
alt="…"
fill
priority // preloads, skips lazy loading
sizes="(max-width: 1024px) 90vw, 420px" // stops the browser fetching a 2000px file for a 420px slot
className="object-cover"
/>Getting sizes wrong is the most common next/image mistake I see — without it, the browser assumes 100vw and downloads a far larger source than the layout needs.
If it's text, the font is blocking. next/font self-hosts and preloads, which removes the third-party connection entirely:
import { Sora } from 'next/font/google';
export const sora = Sora({
subsets: ['latin'],
display: 'swap', // render immediately in a fallback, swap when ready
variable: '--font-sora',
});display: 'swap' trades a flash of fallback text for text that appears immediately. For LCP that's the right trade every time.
If it's data, stream around it. Don't let a slow query hold up the hero:
export default function Page() {
return (
<>
<Hero /> {/* paints immediately */}
<Suspense fallback={<Skeleton />}>
<SlowSection /> {/* streams in later */}
</Suspense>
</>
);
}INP: the one that replaced FID
Interaction to Next Paint measures the full latency of an interaction — input delay, handler execution, and the paint that follows. It replaced First Input Delay in March 2024, and it's much harder to fool: FID only measured the first interaction and ignored everything after it.
Target: under 200 ms.
In React, poor INP is almost always one of three things.
Too much JavaScript on the main thread at hydration. Check what you're shipping:
ANALYZE=true npm run buildThe usual culprits are a 'use client' too high in the tree, a chart or editor library imported eagerly, and a date library pulling in every locale. Push client boundaries to the leaves, and lazy-load anything below the fold:
const Editor = dynamic(() => import('@/components/Editor'), {
ssr: false,
loading: () => <EditorSkeleton />,
});Expensive work inside the handler. Filtering 5,000 rows synchronously on every keystroke blocks paint. Debounce the expensive part, keep the input responsive:
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query); // input updates now, list catches up
const results = useMemo(() => filter(items, deferred), [items, deferred]);useDeferredValue is underused. It lets the text field stay at 60 fps while the heavy list renders at a lower priority.
Re-rendering a whole page for a local state change. If a modal's open/closed state lives in a top-level layout, opening it re-renders everything below. Move state down to the component that owns it.
CLS: the one with the cheapest fixes
Cumulative Layout Shift measures unexpected movement of visible content. Target: under 0.1. Almost every cause has a one-line fix.
Images without dimensions. Reserve the space before the bytes arrive:
// Known size
<Image src="/shot.png" alt="…" width={1200} height={800} />
// Unknown size — constrain the container instead
<div className="relative aspect-video">
<Image src={url} alt="…" fill className="object-cover" />
</div>Fonts with different metrics. When the fallback and the real font have different heights, text reflows on swap. next/font generates a size-adjusted fallback automatically — another reason to use it over a <link> tag.
Content injected above existing content. Banners, cookie notices and async-loaded ads that push the page down. Either reserve the height up front, or overlay instead of inserting:
<div className="min-h-[52px]">
{notice && <Banner notice={notice} />}
</div>Animating layout properties. top, left, width, height and margin all trigger layout. transform and opacity don't — they run on the compositor:
/* ✗ Reflows every frame, and counts toward CLS */
.card:hover { margin-top: -8px; }
/* ✓ Compositor-only */
.card:hover { transform: translateY(-8px); }Measure real users, not just your laptop
Lighthouse runs on a simulated mid-tier device from one location, once. It's a useful development signal and a bad proxy for what people experience.
For real field data, report to your analytics endpoint:
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals((metric) => {
navigator.sendBeacon?.('/api/vitals', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
path: window.location.pathname,
}));
});
return null;
}sendBeacon survives page unload, which matters because CLS and INP are only final when the user leaves.
Then look at the 75th percentile, not the average. Averages hide exactly the users who are having a bad time — and those are the ones Google scores you on.
What actually moved the needle
Ranked by impact per hour spent:
priorityand correctsizeson the LCP image — biggest single win, five minutes.next/fontinstead of a Google Fonts<link>— helps LCP and CLS together.- Pushing
'use client'down to leaf components — the main INP lever in an App Router app. aspect-ratiocontainers on every dynamic image — CLS to near zero.Suspensearound slow sections so the hero never waits.
Not on the list: micro-optimising useMemo and useCallback. In three years of profiling I have never once found those to be the bottleneck. Ship less JavaScript, size your images, reserve your space.