Targets I actually ship against
Google's good thresholds are LCP at or under 2.5s, INP at or under 200ms, and CLS at or under 0.1 at the 75th percentile of field data. Lab scores from Lighthouse on a cable connection can lie. I prioritize CrUX and Search Console field data, then RUM from our own web-vitals beacons.
On a client storefront, lab LCP was 1.8s and field p75 was 3.4s because real users were on mid-range Androids over 4G. We stopped celebrating Lighthouse greens and started optimizing the hero image and TTFB.
I also split dashboards from marketing in reporting. Authenticated apps often look poor on LCP because the largest paint waits on an API. Mixing those cohorts hid a 1.1s LCP regression on the homepage for two weeks before we noticed.
LCP: make the largest element arrive early
Find the LCP element in Performance or the Web Vitals overlay. For us it is usually a hero img, an H1 block with a web font, or a product card image. I set explicit width and height or aspect-ratio, compress to AVIF or WebP, and add fetchpriority high on the LCP image only — never on every image.
I also preload that image in head when it is not discoverable early. CSS background heroes are the worst offenders. Moving a CSS background hero to an img dropped LCP by about 900ms on one landing page.
Before and after on that page at field p75: LCP went from 3.6s to 2.4s after we cut hero weight from about 480KB JPEG to about 95KB AVIF, added preload, and stopped lazy-loading the LCP image. loading=lazy on the hero is a mistake I still catch in PRs.
<!-- In <head> for known LCP image -->
<link rel="preload" as="image" href="/hero.avif" type="image/avif" fetchpriority="high" />
<img
src="/hero.avif"
width="1280"
height="720"
alt="Product dashboard"
fetchpriority="high"
decoding="async"
/>
INP: keep the main thread free after clicks
INP replaced FID. It measures how long the page stays unresponsive after a user interaction across the visit. Our worst INP came from a filters panel that re-rendered a 2,000-row table synchronously on every checkbox toggle.
Fixes that moved p75 INP from about 380ms to about 140ms: debounce expensive handlers at 150ms, virtualize long lists, break work with scheduler.postTask or requestIdleCallback for non-urgent updates, and avoid layout thrashing in event handlers. I profile with the Performance panel Interactions track, not guesses.
Another pattern that hurt us: opening a modal that synchronously parsed a large JSON blob and built a chart. Moving parse and chart construction behind startTransition in React kept the click responsive; the chart filled in a frame later. Users preferred a fast open over a frozen button.
// Bad: sync filter of huge list on every keystroke
input.addEventListener('input', (e) => {
renderRows(filterAll(rows, e.target.value)); // freezes UI
});
// Better: debounce + yield
import { debounce } from './debounce';
const onFilter = debounce((value) => {
const filtered = filterAll(rows, value);
requestAnimationFrame(() => renderRows(filtered));
}, 150);
input.addEventListener('input', (e) => onFilter(e.target.value));
CLS: reserve space before paint
CLS spikes when ads, late fonts, or images without dimensions shove content. I set image dimensions, use font-display optional or swap with a close fallback metric, and reserve min-height for async embeds.
A cookie banner that injected above the fold without reserved space cost us 0.18 CLS on mobile. We fixed it by rendering a 72px placeholder in the initial HTML shell. CLS fell under 0.05 within a week of field data.
Web fonts were the other villain. Swap without matched metrics caused a visible jump of the H1. We switched to a fallback with similar x-height via size-adjust and the shift became hard to notice in field data.
- Always set width/height or aspect-ratio on images and video
- Avoid inserting DOM above existing content without a reserved slot
- Prefer transform animations over top/left that trigger layout
- Load webfonts with a fallback that has similar metrics
- Test CLS with throttling and ad slots enabled — empty lab pages hide it
Wire RUM so arguments end
I ship the web-vitals library attribution build and POST LCP, INP, and CLS to our analytics endpoint with route name, connection type, and device class. Without attribution, LCP is bad is not actionable — you need the element and the phase such as TTFB versus resource load versus render delay.
Sample at 100 percent on low-traffic marketing sites; on high-traffic apps I sample 5 to 10 percent and still get stable p75 within a day. Alert when a route's p75 LCP rises more than 400ms week-over-week after a deploy.
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
route: location.pathname,
navType: metric.navigationType,
});
navigator.sendBeacon('/rum', body);
}
onLCP(send);
onINP(send);
onCLS(send);
Images beyond the hero
Below the fold I use responsive srcset with 1x and 2x and modern formats, and I lazy-load everything that is not the LCP candidate. A product grid that eagerly loaded twelve 200KB JPEGs pushed LCP out because bandwidth contested the hero. After lazy-loading grid images, field LCP improved another roughly 350ms on 4G profiles.
I also cap decoded size. Serving a 4000px-wide asset into a 400px slot wastes memory and decode time on mobile. Our image CDN enforces max width by breakpoint. When marketing insists on a video background, I treat the poster frame as the LCP image and defer video until after load or first interaction.
Server and cache levers that move field LCP
Half of our LCP regressions were TTFB. Edge caching HTML for anonymous marketing pages with 30 to 60s stale-while-revalidate and keeping API origin within the same region as users mattered more than shaving 4KB of JS.
I also audit third parties. One tag manager container added 280ms to LCP on mobile. We deferred non-essential tags until after requestIdleCallback or first interaction. Measure before and after with the same RUM dashboard — screenshots of Lighthouse alone do not convince stakeholders.
Release checklist we paste into PRs
Before merge on customer-facing routes: identify LCP element; confirm preload and fetchpriority; no layout shift from fonts or banners; interaction handlers under about 50ms of sync work on mid-tier CPU; bundle diff does not add a new blocking script in head.
We fail the PR if lab LCP on a throttled mobile profile jumps more than 300ms without a documented reason. That rule alone caught two accidental full-page client hydration of marketing content.
We keep a soft budget of about 170KB gzipped JS for marketing routes. Crossing the budget opens a required comment explaining the trade. That social pressure prevented two accidental charting libraries on the homepage. I paste lab LCP, INP, and CLS on Moto G throttling next to the diff so reviewers stop arguing about vibes.
Key takeaways
- Trust field p75 from CrUX or RUM over a single Lighthouse run on desktop cable.
- LCP usually needs a discoverable, prioritized, correctly sized hero image.
- INP improves when you cut sync main-thread work after clicks and keypresses.
- CLS is mostly reserved space: dimensions, banners, and font fallbacks.
- TTFB and third-party tags often dominate real-user LCP more than micro-optimizations.
- Ship RUM with attribution so you know which element and phase regressed.
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.