Articles › SEO & Web Performance
Core Web Vitals: How to Improve LCP, INP and CLS for Better SEO
A practical Core Web Vitals guide: what LCP, INP and CLS measure, how to test them, and concrete fixes for images, fonts, JavaScript and React apps.
Speed is not just a nice extra: slow pages lose visitors, conversions and search rankings. Google measures real-world user experience with three Core Web Vitals and uses them as a ranking signal. This guide explains each metric, how to measure it correctly and the fixes that actually move the numbers.
The three metrics
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | loading: when the main content appears | ≤ 2.5 s | ≤ 4 s | > 4 s |
| INP — Interaction to Next Paint | responsiveness: delay after clicks/taps/keys | ≤ 200 ms | ≤ 500 ms | > 500 ms |
| CLS — Cumulative Layout Shift | visual stability: unexpected movement | ≤ 0.1 | ≤ 0.25 | > 0.25 |
A page "passes" when 75% of real visits are in the good range for all three. INP replaced FID in 2024; it considers every interaction during the visit, not just the first.
Field data vs lab data
- Field data (RUM) comes from real Chrome users (the Chrome UX Report). This is what Google uses for ranking. See it in PageSpeed Insights ("Discover what your real users are experiencing") and Search Console → Core Web Vitals.
- Lab data comes from a simulated test (Lighthouse, DevTools Performance panel). Great for debugging and reproducible, but it's not your ranking score.
Collect your own field data with the web-vitals library:
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon("/api/vitals", JSON.stringify({
name: metric.name, value: metric.value, rating: metric.rating, page: location.pathname,
}));
}
onLCP(send); onINP(send); onCLS(send);
Tip: Always test on a mid-range phone with throttled network. Your developer laptop on fiber hides almost every problem your users have.
Improving LCP
The LCP element is usually the hero image, a large heading or a banner. LCP breaks down into four parts — fix the biggest one:
- Time to first byte (TTFB) — server response time.
- Resource load delay — how late the browser discovers the LCP image.
- Resource load time — how long the image takes to download.
- Render delay — blocking CSS/JS or client rendering delaying the paint.
Fixes
Make the LCP image discoverable and prioritized:
<img src="/images/hero-1200.avif"
srcset="/images/hero-600.avif 600w, /images/hero-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
width="1200" height="800" alt="Dashboard of the project"
fetchpriority="high">
- Never
loading="lazy"on the LCP image (lazy-load images below the fold only). - If the image is a CSS background or inserted by JavaScript, the browser finds it late — use an
<img>or<link rel="preload" as="image" fetchpriority="high">. - Use AVIF/WebP, correct dimensions and responsive
srcset. A 3 MB PNG hero is the most common LCP problem.
Reduce TTFB: cache rendered pages, use a CDN, enable compression (Brotli/gzip), optimize slow database queries, and keep the server close to your users.
Avoid client-only rendering for content pages: a React SPA shows nothing until the JS bundle downloads, parses and fetches data. Server rendering or prerendering (Next.js, or injecting content server-side as in an ASP.NET Core host) puts the content in the first HTML response.
Inline critical CSS and defer the rest; remove render-blocking third-party scripts from <head>.
Improving INP
INP is bad when the main thread is busy — long JavaScript tasks block the browser from responding to input.
Fixes
Break up long tasks. Anything over 50 ms blocks interaction. Yield to the browser between chunks:
const yieldToMain = () =>
globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));
async function processItems(items) {
for (let i = 0; i < items.length; i++) {
handle(items[i]);
if (i % 100 === 0) await yieldToMain();
}
}
Give immediate visual feedback, then do the heavy work:
function FilterList({ items }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query); // input stays responsive
const filtered = useMemo(() => items.filter((i) => i.title.includes(deferredQuery)), [items, deferredQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filtered} />
</>
);
}
In React, useTransition and useDeferredValue mark expensive updates as non-urgent so typing and clicks stay fast.
Ship less JavaScript: code-split routes (React.lazy), remove unused libraries, replace heavy dependencies (moment.js → native Intl or a small date library), and load analytics/chat widgets after interaction or idle.
Avoid huge DOM trees and layout thrashing: virtualize long lists, and don't read layout (offsetHeight) right after writing styles in a loop.
Move heavy computation to a Web Worker (parsing large files, image processing).
Improving CLS
Layout shift happens when content moves after it's displayed — the classic "I tried to tap a button and an ad pushed it down".
Fixes
Always reserve space for media:
<img src="photo.webp" width="800" height="600" alt="...">
.video-embed { aspect-ratio: 16 / 9; width: 100%; }
img { max-width: 100%; height: auto; }
Reserve space for dynamic content — banners, ads, cookie notices and late-loading components need a min-height or a skeleton with the final size.
Load fonts without shifting text:
@font-face {
font-family: "Vazirmatn";
src: url("/fonts/vazirmatn.woff2") format("woff2");
font-display: swap;
size-adjust: 100%;
}
Self-host fonts, preload the main one, use font-display: swap or optional, and choose a fallback with similar metrics (size-adjust, ascent-override) to minimize the swap shift.
Animate with transform and opacity, never with top, height or margin — transform animations don't shift layout.
Don't insert content above existing content unless it's in response to a user action (shifts within 500 ms of an interaction don't count).
A quick audit checklist
- LCP image is an
<img>withfetchpriority="high", modern format and correct size - No lazy loading above the fold; lazy loading below the fold
- HTML is compressed and cached; TTFB under ~800 ms
- Routes are code-split; JS bundle reviewed with a bundle analyzer
- Third-party scripts deferred or removed
- All images/videos/embeds have dimensions or
aspect-ratio - Fonts self-hosted, preloaded and using
font-display - Long lists virtualized; expensive updates wrapped in transitions
Performance and SEO
Core Web Vitals are a ranking factor, but relevance comes first — a fast page with weak content won't outrank a slower, better answer. Where content is comparable, experience can be the tie-breaker. More importantly, speed directly improves bounce rate and conversions, which matters regardless of rankings.
Summary
LCP measures how fast the main content appears, INP how quickly the page responds, and CLS how stable it is. Use field data to know where you stand and lab tools to find causes. Prioritize and optimize the hero image, reduce TTFB and render content on the server, ship less JavaScript and break up long tasks, and reserve space for everything that loads late. Small, focused fixes often turn a failing page into a passing one.
FAQ
Why is my Lighthouse score high but Search Console says "poor"?
Lighthouse is one simulated load; Search Console reflects real users on real devices and networks over 28 days. Trust field data for SEO.
How long until improvements show in Search Console?
Field data is a 28-day rolling window, so expect about a month to see the full effect.
Do single-page apps have worse Core Web Vitals?
Not necessarily, but client-only rendering often hurts LCP and big bundles hurt INP. Prerendering, code splitting and careful state updates fix most of it.