Core Web Vitals Checklist: Improving LCP, CLS and INP

Key takeaways

Improve LCP, CLS, and INP with concrete techniques: image sizing, fetchpriority, font-display, layout reservation, long-task splitting, and third-party deferral. Measure with field data and Lighthouse.

What Are Core Web Vitals?

Core Web Vitals are a set of metrics Google uses to measure real user experience. They became a Search ranking signal in 2021 as part of Google’s page experience signals. That signal is modest: relevance and content quality dominate ranking, so good vitals will not rescue a thin page, and a page with poor vitals can still rank. The better reason to care is that they measure things users actually notice: how fast the main content loads, whether the page jumps around, and how quickly it responds to clicks and taps.

The three current metrics:

MetricWhat it measuresGood threshold
LCP (Largest Contentful Paint)When the largest visible content element paints≤ 2.5s
CLS (Cumulative Layout Shift)Total unexpected layout shift during load≤ 0.1
INP (Interaction to Next Paint)Delay from user interaction to next visual update≤ 200ms

A page “passes” when the 75th percentile of real page loads meets each threshold, measured separately for mobile and desktop. The 75th percentile is the important detail: a page can feel fast on your laptop and still fail, because a quarter of your visitors on slower phones and networks set the number. The thresholds themselves are Google’s guidelines; segment by country and device type before deciding where the real problems are.


Where to Measure

Field Data (Real Users)

  • PageSpeed Insights: shows CrUX (Chrome User Experience Report) data for your URL — real user percentiles
  • Google Search Console: Core Web Vitals report by page group
  • Web Vitals JavaScript library: collect real user data in your analytics
// Install: npm install web-vitals
import { onLCP, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
    const body = JSON.stringify({
        name: metric.name,
        value: metric.value,
        rating: metric.rating,   // 'good', 'needs-improvement', 'poor'
        id: metric.id,           // unique per page load, for de-duplication
    });
    // CLS and INP are often reported while the page is being hidden or closed;
    // sendBeacon (or fetch with keepalive) survives that, a plain fetch may not.
    if (!navigator.sendBeacon || !navigator.sendBeacon('/analytics', body)) {
        fetch('/analytics', { method: 'POST', body, keepalive: true });
    }
}

onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);

The comment in the middle is the part people get wrong. CLS and INP are not final until the user leaves the page, so the library reports them on visibilitychange to hidden. A normal fetch started at that moment is often cancelled when the tab closes, and the dashboard then under-reports exactly the long, interaction-heavy sessions where the worst values occur. navigator.sendBeacon hands the request to the browser to deliver after the page is gone.

Field data from CrUX (shown in PageSpeed Insights and Search Console) is a 28-day rolling window, so a fix deployed today shows up gradually over the next four weeks. Pages without enough Chrome traffic have no URL-level data at all, and Search Console groups them with similar URLs. Your own RUM data from the snippet above has neither limitation, which is why it is worth collecting even on a small site.

Lab Data (Controlled Environment)

  • Lighthouse (Chrome DevTools, PageSpeed Insights): consistent lab conditions, good for before/after comparison
  • WebPageTest: more realistic network simulation, waterfall charts
  • CI integration: run Lighthouse in GitHub Actions to catch regressions
# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  uses: treosh/lighthouse-ci-action@v12
  with:
    urls: |
      https://your-site.com/
      https://your-site.com/product/
    budgetPath: './budget.json'
    uploadArtifacts: true

Lab tools run one simulated load on one device profile. They are good at catching regressions in CI and at explaining why a load is slow (the waterfall), but they cannot measure INP at all, since there is no real user interacting, and they under-report CLS that happens after load, for example when a user scrolls and lazy content appears. Lighthouse’s Total Blocking Time is the lab stand-in for responsiveness; it correlates with INP but is not the same thing.


LCP: Largest Contentful Paint

LCP measures when the largest image, text block, or video in the viewport finishes rendering. Common LCP elements: hero images, above-the-fold <h1> headings, and banner images.

Step 1: Identify the LCP Element

Open Chrome DevTools → Performance → record a page load → look for the “LCP” marker in the timeline. Or use Lighthouse → LCP section which shows the element.

Common LCP elements by site type:

  • Marketing: hero <img> (most common)
  • Blog: first <img> or <h1>
  • E-commerce: product image above the fold

Step 2: Eliminate Discovery Delays

The LCP element must be discoverable from the initial HTML — not injected by JavaScript:

<!-- GOOD: browser discovers this immediately in the HTML -->
<img src="/hero.webp" alt="Hero image" width="1200" height="630"
     fetchpriority="high" decoding="async">

<!-- BAD: hero image injected by JS — browser discovers it late -->
<div id="hero-container"></div>
<script>
  document.getElementById('hero-container').innerHTML =
    '<img src="/hero.webp" alt="Hero">';
</script>

The browser’s preload scanner reads raw HTML ahead of the parser and starts fetching every <img src> it finds. It cannot see images that JavaScript will create, images set as a CSS background-image, or images whose real URL sits in a data-src attribute for a lazy-loading library. For a client-rendered single-page app, the hero image cannot even be requested until the JavaScript bundle has downloaded, parsed and run. If the LCP image has to be a CSS background or is chosen by script, a <link rel="preload" as="image" href="..." fetchpriority="high"> in the <head> restores early discovery.

Step 3: Set fetch Priority

<!-- fetchpriority="high" tells the browser to fetch this before other images -->
<!-- Only use on the actual LCP element — one per page -->
<img src="/hero.webp"
     alt="Product launch"
     width="1200" height="630"
     fetchpriority="high"
     decoding="async"
     loading="eager">

<!-- For below-the-fold images: lower priority, lazy load -->
<img src="/product-detail.webp"
     alt="Product detail"
     width="800" height="600"
     loading="lazy">

Without the hint, Chrome starts images at low priority and raises the priority of images it finds in the viewport only after layout, which can be hundreds of milliseconds into the load. fetchpriority="high" skips that wait. It is a relative signal, so marking every image high gives back the original problem. The most damaging mistake in this area is loading="lazy" on the LCP image, which frameworks and CMS themes sometimes apply to every image by default: a lazy image is not requested until layout confirms it is near the viewport, which delays exactly the element LCP measures.

Step 4: Optimize Image Format and Size

<!-- Use modern formats with srcset for responsive images -->
<picture>
  <source
    srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1440.avif 1440w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
    type="image/avif">
  <source
    srcset="/hero-480.webp 480w, /hero-960.webp 960w, /hero-1440.webp 1440w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 80vw, 1200px"
    type="image/webp">
  <img src="/hero-1440.jpg" alt="Hero" width="1440" height="810"
       fetchpriority="high">
</picture>

Step 5: Fix Server Response Time (TTFB)

LCP for an image breaks down into four parts: TTFB (until the HTML’s first byte arrives), resource load delay (until the browser starts fetching the image), resource load duration (downloading it), and element render delay (until it is actually painted, which render-blocking CSS and scripts can hold back). Chrome DevTools’ performance panel and the web-vitals attribution build report these parts separately, and the largest one tells you where to work. A slow server delays every part, regardless of image optimization:

# Nginx: compress text responses (images are already compressed)
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;

# Long caching only for fingerprinted files such as app.3f9a1c.js
location ~* \.(webp|avif|jpg|png|css|js)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Two details in this config matter. gzip_types should list text formats only; WebP, AVIF, JPEG and PNG are already compressed, and gzipping them costs CPU for no gain (text/html is always compressed once gzip on is set). And immutable with a one-year lifetime is safe only when file names change with their content. If style.css keeps its name across deploys, returning visitors keep the old file for a year. Cache HTML itself briefly, or not at all, so it can point to new fingerprinted assets.

Use a CDN (Cloudflare, CloudFront, Vercel Edge) to serve assets from a location close to the user. For TTFB, caching the HTML at the edge helps far more than caching images alone, because the HTML is on the critical path for everything else.


CLS: Cumulative Layout Shift

CLS measures unexpected layout shifts: visible elements moving without the user asking for it. It is not limited to page load. Shifts are grouped into “session windows” (bursts of shifts less than a second apart, at most five seconds long) over the page’s whole lifetime, and CLS is the score of the worst window. Shifts within 500 ms after a click, tap or key press are excluded, since the user expects the page to change then. Scrolling and hover do not count as such input, so content that jumps when lazy-loaded during scrolling still hurts the score.

Always Reserve Space for Images

<!-- Without dimensions: browser doesn't know the size until the image loads -->
<!-- Other content shifts down when the image appears -->
<img src="/product.webp" alt="Product">  <!-- BAD: no dimensions -->

<!-- With dimensions: browser reserves space immediately -->
<img src="/product.webp" alt="Product" width="800" height="600">  <!-- GOOD -->
/* CSS alternative: use aspect-ratio to reserve space */
.hero-image {
    aspect-ratio: 16 / 9;
    width: 100%;
    object-fit: cover;
}

Fix Font Loading Shifts

Web fonts are a major source of CLS. When the fallback font swaps to the custom font, text reflowing causes layout shift:

/* font-display: swap shows fallback font immediately, then swaps */
/* Combine with size-adjust to minimize the shift */
@font-face {
    font-family: 'Roboto';
    src: url('/fonts/roboto.woff2') format('woff2');
    font-display: swap;
}

/* size-adjust: scale the fallback font to match the custom font's metrics */
/* Reduces the visual jump when the swap happens (example values) */
@font-face {
    font-family: 'Roboto Fallback';
    src: local('Arial');
    size-adjust: 100.06%;
    ascent-override: 92.7%;
    descent-override: 24.4%;
    line-gap-override: 0%;
}

body {
    font-family: 'Roboto', 'Roboto Fallback', sans-serif;
}

The fallback face only helps if it is actually in the font-family stack, as in the last rule; declaring it alone changes nothing. The override percentages depend on the exact pair of fonts, so compute them rather than copying numbers: tools such as Fontaine, Capsize, or the automatic fallback generation in next/font derive them from the font files. Because Arial is not installed on every platform (many Android devices lack it), a well-matched fallback on desktop may still shift on mobile.

font-display: swap is the reason there is a shift at all. font-display: optional avoids it by using the web font only if it arrives within a very short window and otherwise keeping the fallback for that page view. The trade-off is that first-time visitors on slow connections may never see the brand font on their first page; for body text, many sites accept that in exchange for zero font-related CLS.

Preloading fonts reduces the swap window:

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

Reserve Space for Ads and Dynamic Content

/* Reserve height for an ad slot so surrounding content doesn't shift */
.ad-container {
    min-height: 250px;  /* standard ad height */
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Cookie banner: use fixed positioning to avoid pushing content */
.cookie-banner {
    position: fixed;
    bottom: 0;
    width: 100%;
    /* Does NOT push page content — no layout shift */
}

Ad slots are the hardest case, because an ad network may fill a slot with a 250-pixel creative, a 600-pixel one, or nothing at all. Reserving the most common size removes most of the shift. Collapsing an empty slot to zero height after the page is visible is itself a layout shift, so it is usually better to leave the reserved space with a neutral placeholder. I have seen this pattern account for most of a page’s CLS on content sites: every piece of the article is stable except the in-content ad unit that arrives a second later and pushes the paragraph the reader is on down the screen.

Use transform for Animations

Properties that trigger layout recalculation cause CLS:

/* BAD: animating top/left/margin causes layout recalculation */
.card:hover {
    margin-top: -10px;   /* shifts surrounding elements */
}

/* GOOD: transform does not affect layout — zero CLS impact */
.card:hover {
    transform: translateY(-10px);
}

INP: Interaction to Next Paint

INP measures the delay from a user interaction (click, tap, key press) to the next visual update, across all interactions during the visit, and reports roughly the worst one (for pages with many interactions, a high percentile, so one outlier is ignored). Each interaction’s latency has three parts: input delay (the main thread is busy with something else when the click arrives), processing time (your event handlers), and presentation delay (style, layout and paint of the resulting update). Long-running JavaScript on the main thread is the primary cause of the first two; a huge DOM or expensive CSS usually explains the third.

This is also why INP often looks fine in development and poor in the field. On a fast laptop, a 40 ms handler is invisible. On a mid-range phone the same handler can take several times longer, and it may run while a third-party script’s timer is also executing.

Identify Long Tasks

Long tasks (>50ms on the main thread) delay the browser’s ability to paint after an interaction:

// Observe long tasks with PerformanceObserver
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log('Long task:', entry.duration.toFixed(1), 'ms',
                    'at:', entry.startTime.toFixed(1));
    }
});
observer.observe({ type: 'longtask', buffered: true });

In Chrome DevTools: Performance → record interaction → look for red “Long task” markers in the main thread.

The longtask entry only says that something ran for too long, not which script. For attributing INP, Chrome’s newer Long Animation Frames API (type: 'long-animation-frame') is more useful: its entries list the scripts that ran during the slow frame, with their source URLs, which is often enough to tell your own code apart from a tag manager.

Split Long Work with Scheduler

// Instead of blocking the main thread:
function processAllItems(items) {
    for (const item of items) {
        heavyProcessing(item);   // blocks for 500ms if items.length = 1000
    }
}

// Yield to the browser between chunks:
async function processInChunks(items, chunkSize = 50) {
    for (let i = 0; i < items.length; i += chunkSize) {
        const chunk = items.slice(i, i + chunkSize);
        for (const item of chunk) {
            heavyProcessing(item);
        }
        // Yield: browser can paint and handle other events
        await new Promise(resolve => setTimeout(resolve, 0));
    }
}

// Even better: use scheduler.yield() if available (Chrome 129+)
async function processWithYield(items) {
    for (const item of items) {
        heavyProcessing(item);
        if ('scheduler' in window && 'yield' in scheduler) {
            await scheduler.yield();
        }
    }
}

Yielding lets the browser handle a pending click or paint between chunks, which cuts the input delay part of INP. The two versions differ in what happens after the yield. A setTimeout(0) continuation goes to the back of the task queue, behind any other scripts waiting to run, and browsers clamp nested timeouts to at least 4 ms after a few levels. scheduler.yield() continuation is prioritized ahead of other queued tasks, so the work resumes promptly. In browsers without it, the code above does not yield at all, so in production you would fall back to the setTimeout promise instead of skipping the yield. Yielding after every single item also adds overhead; a common compromise is to yield when about 50 ms have passed since the last yield, checked with performance.now().

Defer Non-Critical Work in Event Handlers

// Don't do everything synchronously on click
button.addEventListener('click', (event) => {
    // Immediate: minimal work needed for the next paint
    updateButtonState(button, 'loading');

    // Deferred: analytics, logging, heavy work after paint
    requestAnimationFrame(() => {
        requestIdleCallback(() => {
            sendAnalytics('button_click');
            prefetchNextPage();
        });
    });
});

The handler does the one thing the user needs to see (the loading state) and schedules the rest. requestAnimationFrame runs just before the next paint, and the requestIdleCallback inside it moves the work to after that paint, when the browser is idle. Safari does not ship requestIdleCallback by default, so production code needs a fallback such as setTimeout(fn, 0). Analytics calls are a surprisingly common INP problem: a tag manager that runs several synchronous listeners on every click adds its processing time to every interaction on the page.

Move Heavy Work Off the Main Thread

// web-worker.js
self.addEventListener('message', (e) => {
    const { data, operation } = e.data;
    let result;

    if (operation === 'sort') {
        result = data.slice().sort((a, b) => a - b);
    } else if (operation === 'filter') {
        result = data.filter(item => item.score > 0.5);
    }

    self.postMessage({ result });
});

// main.js
const worker = new Worker('/web-worker.js');

function heavySortInWorker(data) {
    return new Promise((resolve) => {
        worker.onmessage = (e) => resolve(e.data.result);
        worker.postMessage({ data, operation: 'sort' });
    });
}

button.addEventListener('click', async () => {
    const sorted = await heavySortInWorker(largeDataset);  // main thread free during sort
    renderTable(sorted);
});

This example has a simplification worth knowing about before copying it. worker.onmessage is reassigned on every call, so if the user clicks twice before the first sort finishes, the first promise never resolves and the first result is delivered to the second caller. Real code tags each request with an ID and keeps a map of pending promises, or uses a small library such as Comlink. Also, postMessage copies the data with the structured clone algorithm; for a very large dataset the copy itself takes main-thread time, which can eat much of the gain. Transferable objects (an ArrayBuffer in the transfer list) move the memory instead of copying it. And renderTable still runs on the main thread: if rendering thousands of rows is the slow part, the worker does not help, and virtualizing the table does.

Audit Third-Party Scripts

Third-party scripts (analytics, chat widgets, tag managers) often dominate INP:

// Defer non-critical third parties until after the page is interactive
window.addEventListener('load', () => {
    // Inject chat widget after load
    setTimeout(() => {
        const script = document.createElement('script');
        script.src = 'https://chat-widget.example.com/widget.js';
        script.async = true;
        document.body.appendChild(script);
    }, 3000);   // 3 second delay
});

Or use a facade: show a static preview (e.g., YouTube thumbnail) that loads the real embed only on click.

The fixed delay is a trade-off, not a fix. A widget injected three seconds after load no longer competes with the initial render, but it still runs on the main thread when it arrives, and a user who clicks during its initialization sees a slow interaction. Delaying also reduces what some scripts measure, which is why analytics usually stays early while chat, surveys and social embeds are deferred or replaced by facades.


Prioritization: Where to Start

Not all pages matter equally. Focus efforts where users actually go:

  1. Landing page and product pages: directly affect conversion
  2. Checkout flow: CLS and INP here have direct revenue impact
  3. Search results page: LCP matters for first impression after a Google click

Lower priority:

  • Admin dashboards (no organic traffic, though your own staff still suffer slow INP there)
  • Deep settings pages (low traffic)
  • Pages excluded from search, where only users, not ranking, are affected

Troubleshooting Reference

SymptomMost Likely CauseInvestigation
Lab LCP good, field LCP badSlow device/network, uncached load, ads loadingProfile on a mid-tier Android device with network throttling
LCP element changesCarousel, random hero imagesStabilize the largest element or exclude carousel from LCP candidates
CLS only on mobileDifferent layout, smaller fonts causing different reflowTest on physical device, check mobile-specific CSS
Intermittent CLSFont swap, delayed ad loadingAdd font-display: swap + size-adjust; reserve ad slot dimensions
INP bad on one widgetExpensive click handlerProfile that specific interaction in DevTools Performance
Third-party scripts dominateTag manager, chat widgetAudit with Coverage panel, defer non-critical scripts

Frequently Asked Questions (FAQ)

Q. Why is my LCP still slow even though the hero image is small?

A. LCP measures when the largest element renders, and for images a large part of the delay is often resource load delay: the browser discovers the image late because it is set in CSS, injected by JavaScript, or lazy-loaded. Make the LCP image discoverable in the initial HTML, do not use loading="lazy" on it, and consider fetchpriority="high". Also check TTFB and render-blocking CSS, since a small file cannot paint before the document and styles arrive.