Back to Blog
Performance 14 min read

Core Web Vitals Optimization: The Developer's Complete Guide

M
Mathew· October 30, 2024

A practical, code-heavy guide to optimizing LCP, INP, and CLS — with real before/after examples and the exact techniques we use to consistently score 90+ on PageSpeed.

PageSpeed Insights and Core Web Vitals have become the thing every client asks about. The metrics exist for good reason — Google's research consistently shows correlations between these numbers and real user behavior. We've audited and optimized dozens of sites and consistently score 90+ on PageSpeed for our production builds. Here's the practical breakdown.

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 3–5MB JPEG hero image will destroy your LCP regardless of everything else. Convert to WebP (typically 30–40% smaller than JPEG at equivalent quality) or AVIF (40–55% smaller). In Next.js, the Image component handles format conversion automatically:

<Image
  src="/images/hero.jpg"
  alt="Our team working"
  width={1200}
  height={600}
  priority
  quality={80}
/>

The priority prop adds a <link rel="preload"> for the image and removes lazy loading. Use it on every above-the-fold image. Never use it on images below the fold — you'd be preloading resources the browser would have fetched later anyway, competing with critical resources.

The opacity-0 animation trap

This is the single most common LCP issue we find on sites built with Framer Motion. Developers add a nice entrance animation to the hero headline — fade in from opacity 0. The problem: the headline is almost always the LCP element. If it starts invisible, the browser doesn't count it as "painted" until the animation completes. You're adding your animation duration (typically 300–800ms) directly to your LCP time.

The fix: use a plain HTML element for your LCP content, or start the animation from opacity: 1 and only animate transform (position/scale). Transform animations are GPU-accelerated and don't affect paint timing:

// 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 this to your production site to collect real user metrics. Lab data from Lighthouse is useful, but real user data from CrUX is what Google actually uses in rankings.

One important caveat that saves frustration: Lighthouse scores vary between runs, especially in cold start conditions. Run it 3 times and take the median. And always measure against your production URL or a production build — development mode scores are 20–40 points lower and don't represent what users actually experience.

Core Web VitalsPerformanceSEONext.js
Let's Work Together

Ready to Work With a Software Development Agency That Delivers?

Get a free consultation and project estimate within 24 hours. No fluff — just an honest conversation about your goals, timeline, and budget.

Free consultation24-hour responseNo commitment required