Overview
Core Web Vitals (CWV) are the three Google signals that directly affect search ranking: Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS). Fix them in that order: LCP has the biggest ranking impact, INP is measured by real user interactions, and CLS is the easiest to eliminate once you know the cause. The reference page for each metric lives in core-web-vitals.
Prerequisites
- The site deployed to a publicly reachable URL. Lighthouse can also audit
localhost, but CrUX data is production-only. - Chrome or Chromium installed locally for Lighthouse CLI.
- Node 20 or newer for the Lighthouse CLI.
- Google Search Console access for CrUX data on the live domain.
Steps
1. Run Lighthouse from the CLI
Lighthouse simulates a mobile device on a slow 4G connection, which is the worst-case scenario Google cares most about.
npm install -g lighthouse
lighthouse https://example.com \
--only-categories=performance \
--output=json \
--output-path=./lh-report.json \
--preset=desktop # omit for mobile (default)Extract the three CWV scores:
node -e "
const r = require('./lh-report.json');
const a = r.audits;
console.log('LCP:', a['largest-contentful-paint'].displayValue);
console.log('INP:', a['interaction-to-next-paint']?.displayValue || 'n/a');
console.log('CLS:', a['cumulative-layout-shift'].displayValue);
"2. Check CrUX data in Search Console
Lighthouse is lab data; CrUX is real user data. Open Google Search Console > Core Web Vitals report. Expand each URL group to see which pages fail in the field. Field data lags 28 days; lab data reflects the current deploy.
For pages with no CrUX data (new or low-traffic pages), rely on Lighthouse. For high-traffic pages, prioritize CrUX data over Lighthouse because it captures the distribution of real user conditions.
3. Instrument with the web-vitals JS library
The web-vitals library reports real-user CWV from the browser. Add it to the page to capture production measurements beyond what CrUX aggregates.
npm install web-vitalsimport { onLCP, onINP, onCLS } from "web-vitals";
function sendToAnalytics(metric: { name: string; value: number }) {
// Send to your analytics endpoint
navigator.sendBeacon("/analytics", JSON.stringify(metric));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);4. Triage LCP first
LCP targets: good under 2.5 s, needs improvement up to 4 s, poor above 4 s.
Common causes and fixes:
- Large unoptimized hero image: convert to AVIF/WebP and add
fetchpriority="high". See optimize-images-for-web. - Render-blocking CSS or fonts: inline critical CSS, use
font-display: swap, and preconnect to font origins. - Slow server response (TTFB): cache the HTML response. See nextjs-caching for Next.js ISR and static generation options.
- LCP element is off-screen on mobile: confirm the LCP element is in the viewport; Lighthouse reports which element is the LCP candidate.
5. Triage INP second
INP targets: good under 200 ms, needs improvement up to 500 ms, poor above 500 ms.
INP is the worst interaction during the user’s visit. Common causes:
- Heavy JavaScript on the main thread: code-split and defer non-critical scripts.
- Synchronous DOM updates in event handlers: batch reads and writes with
requestAnimationFrame. - Third-party scripts blocking input: load third-party scripts with
asyncordefer, or use a facade.
Measure with Chrome DevTools > Performance panel; record while clicking buttons or typing. The Long Tasks panel highlights tasks over 50 ms.
6. Triage CLS last
CLS targets: good under 0.1, needs improvement up to 0.25, poor above 0.25.
Layout shift is caused by elements that appear or change size without reserved space. Fix in this order:
- Add explicit
widthandheightto all<img>and<video>elements. - Reserve space for ads and embeds with
min-height. - Avoid inserting content above existing content after load; use
transformfor animations instead of properties that affect layout. See css-animations for layout-safe animation patterns.
<!-- Before: no dimensions, causes layout shift -->
<img src="hero.webp" alt="Hero">
<!-- After: dimensions reserved, no shift -->
<img src="hero.webp" width="1200" height="600" alt="Hero">7. Re-measure after each fix
Deploy one fix at a time and re-run Lighthouse before moving to the next. Each change compounds; measuring after every fix confirms which ones had impact.
lighthouse https://example.com --only-categories=performance --output=json \
--output-path=./lh-after.jsonVerify it worked
# 1. Lighthouse performance score is above 90.
node -e "const r=require('./lh-after.json'); console.log(r.categories.performance.score * 100)"
# 91 or above
# 2. LCP is under 2.5 s.
node -e "const r=require('./lh-after.json'); console.log(r.audits['largest-contentful-paint'].numericValue)"
# under 2500
# 3. CLS is under 0.1.
node -e "const r=require('./lh-after.json'); console.log(r.audits['cumulative-layout-shift'].numericValue)"
# under 0.1Common errors
- Lighthouse score varies between runs. Network and CPU throttling introduces variance. Run 3 times and average, or use
--throttling-method=providedfor stable CI results. - CrUX shows “Not enough data”. The page has fewer than 1,000 real-user sessions in the last 28 days. Use Lighthouse as the sole benchmark for low-traffic pages.
- CLS is zero in Lighthouse but high in CrUX. Lighthouse does not scroll; many CLS-causing elements appear only after scroll or after ads load. Add
web-vitalsinstrumentation and measure in production. - INP is missing from Lighthouse output. INP requires user interaction; Lighthouse only simulates page load. Measure INP with the
web-vitalslibrary or with Chrome’s Performance panel during a recorded session. - Score regresses after a code change. Images added without
width/heightor a new third-party script is the most common cause. Run Lighthouse in CI on every PR to catch regressions before they ship.