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:

  1. PageSpeed Insights — measures against real user data (CrUX) for your URL, plus a fresh Lighthouse audit. Most accurate for real-world impact.
  2. Chrome DevTools Performance tab — best for diagnosing specific long tasks and main thread blocking. Record a trace while interacting with the page.
  3. 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.

Want help with your own project?

A few lines is enough. We'll ask the right questions on the call.

  1. We reply within one working day

    By email or WhatsApp, whichever you prefer.

  2. A short call

    To understand the goal, the people who'll use it and any deadline.

  3. A written scope and estimate

    Before any work starts, so you know what you're agreeing to.

So we can reply on WhatsApp if you prefer.

What are you building, who is it for, and is there a deadline?

We reply within one working day. By sending this you agree that Teknoesis (Lahore, Pakistan) may contact you about your request and store your details as described in our Privacy Policy. We use analytics tools; see Your Privacy Choices in the footer.