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.
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
The LCP image. Too big, wrong format, lazy-loaded, or discovered late. Usually an afternoon, and usually the largest single win.
- 2
Reserved space. Images, ads and injected banners without dimensions. Cheap, and it fixes CLS outright rather than improving it.
- 3
Render-blocking head. Fonts and stylesheets that delay first paint for everyone.
- 4
Third-party scripts. Tag managers, chat widgets, review embeds. This is where INP goes to die.
- 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.