How it works Features Pricing Docs About

Fixing the metrics

Knowing your LCP is 4.1 s does not fix it. This is what actually moves each number, roughly in the order it tends to pay off.

12 min read

Work in this order

Almost every failing page we see is failing for one of a handful of reasons, and the cheap causes are far more common than the expensive ones. Before rewriting anything, walk this list:

  1. 1

    The LCP image. Too big, wrong format, lazy-loaded, or discovered late. Usually an afternoon, and usually the largest single win.

  2. 2

    Reserved space. Images, ads and injected banners without dimensions. Cheap, and it fixes CLS outright rather than improving it.

  3. 3

    Render-blocking head. Fonts and stylesheets that delay first paint for everyone.

  4. 4

    Third-party scripts. Tag managers, chat widgets, review embeds. This is where INP goes to die.

  5. 5

    Server response time. Real, but usually the expensive fix — and pointless while a 1.4 MB hero is sitting above it.

Only when those are clean is it worth discussing bundle splitting, hydration strategy or a framework change.

LCP — the largest paint

LCP is almost always an image or a heading. The report names the element, so start there rather than guessing.

Serve the right bytes. An image displayed at 390 px wide does not need to be 2400 px. Modern formats cut another 30–50% on top:

<img src="/hero-800.jpg"
     srcset="/hero-400.avif 400w,
             /hero-800.avif 800w,
             /hero-1600.avif 1600w"
     sizes="(max-width: 700px) 100vw, 800px"
     width="800" height="450"
     fetchpriority="high"
     alt="…">

Never lazy-load it. loading="lazy" on the LCP element is the single most common self-inflicted LCP problem: the browser deliberately delays the one image you needed first. Lazy-load everything below the fold instead.

Make it discoverable early. An image set by CSS background-image, or injected by JavaScript, cannot be found until the stylesheet or script has run. Put the LCP image in the HTML as an <img>, or preload it:

<link rel="preload" as="image" href="/hero-800.avif"
      imagesrcset="/hero-400.avif 400w, /hero-800.avif 800w">

If your LCP is text, the delay is usually the font. See the render-blocking section below — font-display: swap alone often takes a second off.

CLS — things moving under the reader

CLS is the most fixable of the three, because it has a small number of causes and each has a definite fix rather than an optimisation.

Every image and video gets dimensions. Either the width and height attributes, or a CSS aspect-ratio. The browser then reserves the box before the file arrives:

<img src="/card.avif" width="640" height="360" alt="…">

/* or, for a responsive box */
.card-media { aspect-ratio: 16 / 9; width: 100%; }

Reserve space for anything injected. Cookie bars, promo strips, ad slots and “sticky” headers that appear a moment after load push the whole page down. Give the slot a min-height that matches what will land in it, or render it server-side.

Never insert content above existing content once the page is visible, unless it is in response to a user action — interactions get a 500 ms grace window, spontaneous shifts do not.

Watch web fonts. A fallback with different metrics reflows every line when the real font arrives. size-adjust and ascent-override on the fallback, or the font-family: X, local-fallback pattern, removes the jump.

INP — how fast the page answers

INP measures the worst delay between a tap and the screen changing, across the whole visit. It is not about load time; it is about what the main thread is busy doing when someone tries to use the page.

Find the long tasks. Anything over 50 ms blocks input. The lab run reports total blocking time; Chrome DevTools’ Performance panel shows you which function.

Break up the work. Yield to the browser between chunks so a queued tap gets served:

async function processAll(items) {
  for (const item of items) {
    doWork(item);
    // let pending input run before the next chunk
    await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
  }
}

Separate the feedback from the work. Paint the visible response first, then do the expensive part:

button.addEventListener('click', () => {
  setSelected(true);                 // cheap, visible, immediate
  requestAnimationFrame(() => {
    requestAnimationFrame(() => recalculateEverything());
  });
});

Defer third parties. Chat widgets, A/B testing and tag managers commonly own the worst INP on a page. Load them after interaction, on idle, or behind consent — and measure the difference, because the numbers are often startling.

Beware large DOMs. If a click triggers layout across 10,000 nodes, no amount of yielding helps. Virtualise long lists.

The render-blocking head

Everything in <head> that is not async delays the first pixel for every visitor on every page.

Fonts. Self-host, preload the one face that matters, and always set font-display: swap so text is readable while the font loads:

<link rel="preload" as="font" type="font/woff2"
      href="/fonts/body.woff2" crossorigin>

@font-face {
  font-family: "Body";
  src: url("/fonts/body.woff2") format("woff2");
  font-display: swap;
}

CSS. One render-blocking stylesheet is fine; five is not. Inline what the first viewport needs and load the rest with media="print" onload="this.media='all'" if you must split it.

Scripts. defer by default. async only when execution order genuinely does not matter. A plain <script> in the head stops HTML parsing dead.

Proving it worked

This is where most performance work goes wrong: the fix ships, the lab score jumps, and everyone declares victory while the field numbers have not moved at all.

Re-scan immediately after deploy and the lab numbers should confirm the change — that is the fast feedback loop. The field numbers cannot confirm anything yet: CrUX reports a 28-day rolling window, so a fix that landed today is competing with 27 days of the old behaviour. Expect to see movement begin within about a week and settle after four.

Put the page on Performance Pulse before you deploy, not after. Then the improvement shows up on the trend on its own, and if the fix regresses something else you hear about it without checking.

A perfect lab score is not a pass

Lighthouse runs one load on a simulated mid-range phone. It is excellent at telling you what is slow and completely unqualified to tell you whether your visitors are having a good time. When the two disagree, the field data is the one that counts.