PerformancePublished 1/31/2025

Core Web Vitals: What Drives LCP, INP, and CLS, and How to Fix Them

Starting from the gap between Lighthouse and CrUX numbers, we'll look at where LCP, INP, and CLS each get stuck, and what to do about it.

Why Care About Core Web Vitals?

"The site is fast" is a pretty vague claim. Does a quick first paint count? What about a button that responds the instant you click it? And if the content jumps around halfway through loading, is that slow?

Core Web Vitals are a set of metrics from Google that break "how the experience feels to users" into three dimensions, each represented by a number you can measure:

MetricWhat it measuresGoodPoor
LCP (Largest Contentful Paint)Loading: when the main content appears≤ 2.5 s> 4 s
INP (Interaction to Next Paint)Interactivity: how fast the page responds≤ 200 ms> 500 ms
CLS (Cumulative Layout Shift)Stability: whether the layout jumps≤ 0.1> 0.25

Anything in between is Needs Improvement. There's one easily missed detail, too: the verdict is based on the 75th percentile (p75) of real-user data. In other words, three out of four visits have to hit the target for a metric to pass. It's not the average, and it's not the number you got on your own laptop.

By the way, INP officially replaced FID (First Input Delay) as one of the three metrics in March 2024. FID only looked at the input delay of the first interaction. INP looks at the overall responsiveness of every interaction during a visit, which is a much stricter bar.

LCP: When Does the Main Content Show Up?

LCP records when the largest content element in the viewport finishes rendering. That's usually a hero image, a video poster, or a big block of text above the fold.

Break LCP into Four Phases

When LCP is slow, "the image is too big" isn't a useful diagnosis. A better approach is to split the time into four phases and see where it actually goes:

  1. TTFB (Time to First Byte): from the request being sent to the first byte coming back
  2. Resource load delay: the gap between TTFB and the browser starting to load the LCP resource
  3. Resource load duration: how long the LCP resource takes to download
  4. Element render delay: the gap between the download finishing and the element actually being painted

This breakdown matters because each phase has a completely different fix. In practice, the bottleneck often isn't the download (phase 3) at all. It's phases 2 and 1, meaning the browser discovers the image far too late. Both the Performance panel in DevTools and PageSpeed Insights show this breakdown.

Fixes for Each Phase

TTFB is too slow: slow server processing, no CDN, too many redirects. Put a CDN in front, lean on caching, pre-generate whatever pages you can (SSG or ISR), and cut unnecessary redirects.

Resource load delay is too long: if your LCP image is loaded through CSS background-image, or inserted dynamically by JavaScript, the browser's preload scanner can't see it. Nothing starts downloading until the CSS or JS has run. The fix is to put the image straight into the HTML as an <img> and explicitly raise its priority:

html
<!-- Lets the browser discover and prioritize the download early in HTML parsing -->
<img src="/hero.webp" alt="Homepage hero" width="1200" height="600" fetchpriority="high" />

Here's a common trap: don't put loading="lazy" on your LCP image. Lazy loading makes the browser wait until layout is done and it has confirmed the image is in the viewport before it starts fetching. Delay the LCP image like that and your score takes a direct hit. Save lazy for images below the fold.

Resource load duration is too long: the resource itself is too big. Switch to WebP or AVIF, and use srcset and sizes to serve the right dimensions per device so phones don't download desktop-sized images.

Element render delay is too long: the resource loaded ages ago, but nothing is on screen yet. The usual suspects are render-blocking CSS, synchronous JavaScript, or a client-side rendered page that has to download and run JavaScript before it can produce any content. Inline your critical CSS, defer non-essential scripts, and switch to SSR or SSG where it makes sense, so the LCP element is already in the initial HTML.

Fonts matter too when the LCP element is text. Setting font-display: swap lets the text render in a fallback font right away instead of waiting for the custom font to download.

INP: How Fast Does the Page React After a Click?

INP measures the time from a user interaction (click, tap, or key press) to the next frame the browser paints. A page can have lots of interactions, and INP picks out the slowest ones, so a single sluggish interaction can drag the whole score down.

Three Phases of an Interaction

Just like with LCP, we'll break a single interaction into pieces:

  1. Input delay: after the event fires, the main thread is busy with something else, so the event handler can't start
  2. Processing time: how long the event handler itself takes to run
  3. Presentation delay: after the handler finishes, the time the browser needs to recalculate styles, compute layout, and paint the next frame

Nearly every root cause points to the same thing: the main thread is occupied. JavaScript is single-threaded. When a long task is sitting on the call stack, the user's click has to wait in line. That's why we keep saying to avoid long tasks (anything over 50 ms).

Fixes for Each Phase

Input delay is too long: the main thread was busy when the user interacted. Common culprits are heavy JavaScript initialization right after page load, third-party scripts (ads, trackers, chat widgets), and scheduled heavy computation. Ship less JavaScript and defer what you can, load third-party scripts with async or defer, and consider a Web Worker when it fits.

Processing time is too long: the event handler is doing too much. The core principle is "respond on screen first, do the rest later." Break long tasks into small chunks and hand the main thread back to the browser in between:

js
button.addEventListener('click', async () => {
  // Do what must show up on screen right away
  showLoadingState()
 
  // Hand control back to the browser so it can paint first
  await yieldToMain()
 
  // Then do the expensive work
  processLargeData()
})
 
function yieldToMain() {
  // Prefer scheduler.yield() when available — it resumes the original task first after yielding
  if (globalThis.scheduler?.yield) {
    return scheduler.yield()
  }
  // Fall back to setTimeout to yield the main thread in other browsers
  return new Promise((resolve) => setTimeout(resolve, 0))
}

In React, if a single keystroke triggers a pile of re-renders, you can use useTransition or useDeferredValue to mark the secondary updates as low priority, so the input responds first:

js
const [isPending, startTransition] = useTransition()
 
function handleChange(e) {
  setInput(e.target.value) // urgent: the input must update immediately
 
  startTransition(() => {
    setFilteredList(filter(e.target.value)) // non-urgent: can be computed later
  })
}

Presentation delay is too long: updating the screen is itself too expensive. Think huge DOM trees, complex style calculations, or forced synchronous layout (layout thrashing). Shrink the DOM, use virtualization for long lists, and apply CSS content-visibility: auto to offscreen sections so the browser can skip rendering them.

CLS: Why Does the Layout Keep Jumping?

You've probably been here: you go to tap a button, an image suddenly loads above it, the button gets pushed away, and you end up tapping something else. CLS measures exactly this kind of unexpected layout shift.

Here's how it's calculated. Each shift scores impact fraction × distance fraction. Shifts that happen close together are grouped into a session window, and the worst window is the one that counts. Shifts that happen within 500 ms of a user interaction (like opening a menu) are excluded, since those are expected.

Common Causes and Fixes

Images, videos, and iframes with no reserved space: before the resource loads, the browser has no idea how big it will be, so once it arrives, it shoves everything below it out of the way. The fix is to declare dimensions explicitly so the browser can reserve the space up front:

html
<!-- With width and height, the browser works out the aspect ratio and reserves space -->
<img src="/photo.webp" alt="Description" width="800" height="450" />
css
/* In responsive layouts, you can also set the ratio directly with aspect-ratio */
.video-wrapper {
  aspect-ratio: 16 / 9;
}

Dynamically injected content: ads, cookie consent banners, and lazily loaded recommendation blocks push existing content around when they squeeze in from the top or middle of the page. Reserve a fixed-height container (min-height) for them, or place them where they don't disturb existing content, such as a fixed-position overlay. Whatever you do, don't insert new content above something the user is already looking at.

Font loading shifts: fallback and custom fonts have different character widths and line heights, so the text reflows the moment the font swaps. Besides font-display, you can use descriptors like size-adjust and ascent-override to make the fallback font look as close to the custom one as possible, and add <link rel="preload"> to fetch the critical font earlier.

Animating properties that trigger layout: animating top, left, or height makes surrounding elements move too. Use transform and opacity instead. They don't affect layout and are handled entirely in the compositing stage. We also touched on this in the article on animation performance.

One more thing that indirectly affects the experience is whether your page is eligible for the bfcache (Back/Forward Cache). When a user hits the back button and the page is restored straight from the bfcache, it loads almost instantly and there are no reload-related shifts either. So avoid the unload event, or your page can lose its bfcache eligibility.

Summary

  • Core Web Vitals cover loading (LCP), interactivity (INP), and stability (CLS), and pass or fail is decided by the p75 of real-user data
  • For LCP, the key is to break the time into four phases. A common bottleneck is the browser finding the hero image too late, so use fetchpriority="high" and never put loading="lazy" on an above-the-fold hero image
  • INP is fundamentally about an occupied main thread. The fix is to break up long tasks, yield to the main thread, and ship less unnecessary JavaScript
  • CLS is fundamentally about "no reserved space." Give images dimensions, put dynamic content in containers with reserved room, and animate with transform, and you'll solve most of it

The principle never changes: measure first, find the biggest bottleneck, change only what needs changing, then go back and verify.