Performance
Core Web Vitals Optimization: The Developer's Complete Guide
A practical, code-heavy guide to improving LCP, INP and CLS: what usually causes each problem, how to fix it, and how to measure it properly.
By TeknoesisPublished Updated 14 min read
Core Web Vitals measure what visitors feel: how fast the main content appears, how quickly the page responds, and whether it jumps around. Here's the practical breakdown we use when we tune our own sites, including this one.
The Three Metrics
Google's Core Web Vitals measure three specific aspects of user experience:
- LCP (Largest Contentful Paint) — How quickly your main content loads. Target: under 2.5 seconds. This is typically your hero image or the main headline.
- INP (Interaction to Next Paint) — How quickly the page responds to user interactions. Target: under 200ms. This replaced FID in March 2024 and is harder to optimize.
- CLS (Cumulative Layout Shift) — How much the page visually jumps around during loading. Target: under 0.1. This is the frustrating "I was about to click that but the page shifted" experience.
Fixing LCP
LCP is the one we fix most frequently. The causes, in order of how often we encounter them:
Unoptimized images
A multi-megabyte JPEG hero will hurt LCP whatever else you do. WebP and AVIF files are usually much smaller than JPEGs at similar quality. In Next.js, the Image component converts formats for you:
<Image
src="/images/hero.jpg"
alt="The product dashboard"
width={1200}
height={600}
fetchPriority="high"
/>
Give the image that's most likely to be your LCP element a high fetch priority (in Next.js 16, fetchPriority="high" or the new preload prop, which replaces the deprecated priority). Don't do it for images below the fold: they would compete with the resources that matter.
The opacity-0 animation trap
A common LCP problem on animated sites: the hero headline fades in from opacity 0. The headline is often the LCP element, and LCP is recorded when it first renders visibly, so any delay before it appears is added to your LCP time.
The fix is to keep LCP content visible from the first paint. If you want motion, animate other elements, or move the headline with a transform while it stays visible:
// Don't do this for your LCP element
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
// This is fine — doesn't affect LCP
<motion.h1 initial={{ y: 20, opacity: 1 }} animate={{ y: 0 }}>
Render-blocking resources
Synchronous JavaScript and CSS in <head> block the browser from rendering anything. In Next.js, this is largely handled for you. But check for third-party scripts you've added manually — analytics, chat widgets, A/B testing tools. Load them with strategy="lazyOnload" or strategy="afterInteractive":
<Script
src="https://widget.intercom.io/widget/abc123"
strategy="lazyOnload"
/>
Fixing INP
INP is the metric most developers struggle with because it's less intuitive than LCP. It measures the delay between any user interaction (click, tap, keyboard press) and when the browser actually paints a response. The target is 200ms total — from input to visual feedback.
Poor INP usually means you have long tasks blocking the main thread. A long task is any JavaScript execution that runs for more than 50ms without yielding. Common causes:
Heavy state updates causing large React re-renders
// Problem: updating state triggers re-render of large tree
const handleFilter = (value: string) => {
setFilteredItems(items.filter(item => item.name.includes(value)));
};
// Solution: use useTransition to defer non-urgent updates
const [isPending, startTransition] = useTransition();
const handleFilter = (value: string) => {
startTransition(() => {
setFilteredItems(items.filter(item => item.name.includes(value)));
});
};
Synchronous localStorage reads
// Bad: blocks render on every interaction
const Component = () => {
const theme = localStorage.getItem("theme"); // synchronous
// ...
};
// Good: read once on mount, store in state
const [theme, setTheme] = useState("light");
useEffect(() => {
setTheme(localStorage.getItem("theme") ?? "light");
}, []);
Unthrottled event handlers
// Debounce expensive operations triggered by user input
const debouncedSearch = useMemo(
() => debounce((query: string) => fetchResults(query), 300),
[]
);
Fixing CLS
Layout shift happens when elements jump after initial render. The fixes are usually straightforward once you know what to look for.
Set explicit image dimensions
<!-- Bad: browser doesn't reserve space, page shifts when image loads -->
<img src="/product.jpg" alt="Product" />
<!-- Good: browser reserves space immediately -->
<img src="/product.jpg" alt="Product" width="800" height="600" />
Reserve space for dynamic content
Ads, embeds, and dynamically loaded components are common CLS sources. Set a min-height on containers before the content loads:
<div className="min-h-[250px]">
{adContent ?? <div className="h-[250px] bg-gray-50 animate-pulse" />}
</div>
Preload custom fonts
Font swapping causes visible layout shift. Preload your fonts and use font-display: swap with a size-adjusted fallback:
<link
rel="preload"
href="/fonts/inter-variable.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
Measuring Correctly
Use tools in this order:
- PageSpeed Insights — measures against real user data (CrUX) for your URL, plus a fresh Lighthouse audit. Most accurate for real-world impact.
- Chrome DevTools Performance tab — best for diagnosing specific long tasks and main thread blocking. Record a trace while interacting with the page.
- web-vitals npm package: add it to your production site to collect real-user metrics. Lab data from Lighthouse is useful for debugging, but Google's page experience signals use field data from real Chrome users (CrUX).
One caveat that saves frustration: Lighthouse scores vary between runs. Run it a few times and take the median, and always measure a production build or your live URL. Development-mode scores are much lower and don't represent what visitors experience.
Speed work like this is part of every site we build and look after. For a site designed to download as little as possible, see the Rice Purity Test case study: three runtime dependencies, and answers that never leave the browser.