Core Web Vitals are essential metrics that assess the speed, interactivity, and visual stability of websites and applications. These metrics are crucial for SEO as they significantly impact search result rankings and user experience. Improving Core Web Vitals leads to better site visibility and enhanced user engagement.

Table of Contents

What are Core Web Vitals?

Core Web Vitals assess the speed, interactivity, and visual stability of a website or application. These metrics are crucial for SEO as they significantly impact search result rankings and user experience.

The Three Core Web Vitals

Metric Description Status
LCP Measures largest visible element rendering time Active
CLS Measures visual stability and unexpected layout shifts Active
INP Measures responsiveness to user interactions Active

Largest Contentful Paint (LCP)

LCP measures the timing for the browser to render the largest text or image element visible in the viewport. It directly affects perceived page speed and user satisfaction.

LCP Thresholds

Score Rating Performance
≤ 2.5s Good Excellent
2.5-4s Needs Improvement Fair
> 4s Poor Critical

Ways to Improve LCP

1. Setup Lazy Loading

<img src="image.jpg" loading="lazy" alt="Description" />

2. Minify CSS and JavaScript

  • Remove unnecessary code
  • Compress files using gzip or brotli
  • Use production builds

3. Use Caching

  • Browser caching with cache headers
  • CDN caching for static assets
  • Server-side caching strategies

4. Optimize Server Response Time

  • Upgrade hosting or server resources
  • Use a Content Delivery Network (CDN)
  • Implement server-side caching
  • Optimize database queries

5. Eliminate or Defer Third-party Scripts

  • Load scripts asynchronously
  • Defer non-critical scripts
  • Evaluate necessity of third-party tools

6. Minimize Render-blocking Resources

  • Inline critical CSS
  • Defer non-critical CSS
  • Use async/defer on scripts

7. Compress and Optimize Images

Use responsive images with multiple sizes and formats:

Basic Responsive Image:

<picture>
    <source srcset="your-path.avif" type="image/avif" />
    <source srcset="your-path.webp" type="image/webp" />
    <source srcset="your-path.jpg" type="image/jpeg" />
    <img
        src="your-path.jpg"
        alt="A description of the image."
        width="100"
        height="100"
        loading="lazy"
        decoding="async"
    />
</picture>

Mobile and Desktop Images:

<picture>
    <source
        media="(max-width: 767.98px)"
        srcset="your-smaller-device-image-path.png"
        sizes="100vw"
    />
    <source
        media="(min-width: 768px)"
        srcset="your-default-image-path.png"
        sizes="100vw"
    />
    <img
        src="your-default-image-path.png"
        alt="A description of the image."
        width="100"
        height="100"
        loading="lazy"
        decoding="async"
    />
</picture>

Responsive Images with Multiple Resolutions:

<picture>
    <source
        media="(max-width: 767.98px)"
        srcset="
            your-smaller-device-image-path-250w.png 250w,
            your-smaller-device-image-path-350w.png 350w,
            your-smaller-device-image-path-500w.png 500w,
            your-smaller-device-image-path-700w.png 700w
        "
        sizes="100vw"
    />
    <source
        media="(min-width: 768px)"
        srcset="
            your-default-image-path-1380w.png 1380w,
            your-default-image-path-1500w.png 1500w,
            your-default-image-path-2500w.png 2500w
        "
        sizes="100vw"
    />
    <img
        src="your-default-image-path.png"
        alt="A description of the image."
        width="100"
        height="100"
        loading="lazy"
        decoding="async"
    />
</picture>

Monitoring LCP in JavaScript

Refer to LCP breakdown in JavaScript for more information and example usage below.

js
$(function () {
    const LCP_SUB_PARTS = [
        "TTFB (Time to first byte)",
        "Load delay",
        "Load time",
        "Render delay",
    ];

    function formatTime(milliseconds) {
        const seconds = milliseconds / 1000;
        return seconds.toFixed(2) + "s";
    }

    function getLCPStatus(lcpTime) {
        if (lcpTime <= 2500) {
            return "Good";
        } else if (lcpTime <= 4000) {
            return "Needs Improvement";
        } else {
            return "Poor";
        }
    }

    new PerformanceObserver((list) => {
        const lcpEntry = list.getEntries().at(-1);
        const navEntry = performance.getEntriesByType("navigation")[0];
        const lcpResEntry = performance
            .getEntriesByType("resource")
            .filter((e) => e.name === lcpEntry.url)[0];
        if (!lcpEntry.url) return;
        const ttfb = navEntry.responseStart;
        const lcpRequestStart = Math.max(
            ttfb,
            lcpResEntry ? lcpResEntry.requestStart || lcpResEntry.startTime : 0
        );
        const lcpResponseEnd = Math.max(
            lcpRequestStart,
            lcpResEntry ? lcpResEntry.responseEnd : 0
        );
        const lcpRenderTime = Math.max(
            lcpResponseEnd,
            lcpEntry ? lcpEntry.startTime : 0
        );

        LCP_SUB_PARTS.forEach((part) => performance.clearMeasures(part));

        const lcpSubPartMeasures = [
            performance.measure(LCP_SUB_PARTS[0], {
                start: 0,
                end: ttfb,
            }),
            performance.measure(LCP_SUB_PARTS[1], {
                start: ttfb,
                end: lcpRequestStart,
            }),
            performance.measure(LCP_SUB_PARTS[2], {
                start: lcpRequestStart,
                end: lcpResponseEnd,
            }),
            performance.measure(LCP_SUB_PARTS[3], {
                start: lcpResponseEnd,
                end: lcpRenderTime,
            }),
        ];

        const lcpStatus = getLCPStatus(lcpRenderTime);

        const tableData = lcpSubPartMeasures.map((measure) => ({
            "LCP Phase": measure.name,
            "% of LCP": `${Math.round((1000 * measure.duration) / lcpRenderTime) / 10}%`,
            Timing: formatTime(measure.duration),
        }));

        tableData.push({
            "LCP Phase": "TOTAL",
            "% of LCP": "100%",
            Timing: formatTime(lcpRenderTime),
            Status: lcpStatus,
        });

        console.table(tableData);
        console.log("LCP element: ", lcpEntry.element, lcpEntry.url);
    }).observe({
        type: "largest-contentful-paint",
        buffered: true,
    });
});

Cumulative Layout Shift (CLS)

CLS measures the visual stability of a webpage by tracking how much content moves unexpectedly during loading. A high CLS score indicates poor user experience, as it can cause users to lose their place or click on wrong elements. Minimizing layout shifts ensures a more stable and user-friendly interface.

CLS Thresholds

Score Rating Performance
≤ 0.1 Good Excellent
0.1-0.25 Needs Improvement Fair
> 0.25 Poor Critical

Why CLS Matters

  • User Engagement: Users are less likely to abandon sites with unexpected layout shifts
  • Accessibility: Unexpected content movement makes navigation difficult for all users
  • SEO and Ranking: Google considers layout stability as a ranking factor

Common Issues and Fixes

1. Unset Image and Video Dimensions

Always set explicit width and height attributes on images and videos:

<img
    src="image.jpg"
    alt="Description"
    width="400"
    height="300"
    loading="lazy"
/>

2. Web Fonts Causing Layout Shifts

Use font-display: swap to prevent flash of invisible text (FOIT):

css
@font-face {
    font-family: "CustomFont";
    src: url("font.woff2") format("woff2");
    font-display: swap;
}

3. Slow Interactions and Form Submission

Provide visual feedback immediately during interactions to prevent layout shifts from responses.

Monitoring CLS

Use the CLS Visualizer Chrome Extension or check with Lighthouse to identify layout shift culprits in the Performance tab of Chrome DevTools.


Interaction to Next Paint (INP)

INP is a metric that evaluates a webpage’s responsiveness to user interactions by measuring the latency of all clicks, taps, and keyboard actions during a user’s visit. The final INP value represents the longest interaction recorded, excluding any outliers.

INP Thresholds

Score Rating Performance
≤ 200ms Good Excellent
200-500ms Needs Improvement Fair
> 500ms Poor Critical

How INP is Calculated

INP is calculated by monitoring all interactions with a webpage. For most sites, the interaction with the highest latency is reported as the INP. The metric excludes outliers to focus on typical user experience.

Strategies to Improve INP

1. Optimize Long Tasks

Break up long-running JavaScript into smaller chunks that the browser can execute without blocking user input:

js
// Use async operations to avoid blocking
async function processLargeDataset(data) {
    for (let i = 0; i < data.length; i += 100) {
        await new Promise((resolve) => setTimeout(resolve, 0));
        // Process batch of 100 items
    }
}

2. Optimize Input Delay

Minimize the time between user interaction and script processing by:

  • Moving expensive calculations off the main thread using Web Workers
  • Deferring non-critical operations
  • Using requestIdleCallback() for background tasks

3. Use Simple CSS Selectors

Complex selectors take longer to evaluate. Prefer simple, direct selectors:

css
/* Good - Simple selectors */
.button {
}
#header {
}

/* Avoid - Complex selectors */
div.container > ul.menu li.item:nth-child(2n + 1) a.link {
}

Monitoring INP in JavaScript

Refer to INP breakdown in JavaScript for complete implementation details and the additional INP information resource. Below is an example monitoring function:

js
$(function () {
    const RATING_COLORS = {
        good: "#0CCE6A",
        "needs-improvement": "#FFA400",
        poor: "#FF4E42",
    };

    function onInteraction(callback) {
        const valueToRating = (score) =>
            score <= 200 ? "good" : score <= 500 ? "needs-improvement" : "poor";

        const observer = new PerformanceObserver((list) => {
            const interactions = {};

            for (let entry of list
                .getEntries()
                .filter((entry) => entry.interactionId)) {
                interactions[entry.interactionId] =
                    interactions[entry.interactionId] || [];
                interactions[entry.interactionId].push(entry);
            }

            for (let interaction of Object.values(interactions)) {
                const entry = interaction.reduce((prev, curr) =>
                    prev.duration >= curr.duration ? prev : curr
                );
                const entryTarget = interaction
                    .map((entry) => entry.target)
                    .find((target) => !!target);
                const value = entry.duration;

                callback({
                    attribution: {
                        eventEntry: entry,
                        eventTarget: entryTarget,
                        eventTime: entry.startTime,
                        eventType: entry.name,
                    },
                    entries: interaction,
                    name: "Interaction",
                    rating: valueToRating(value),
                    value,
                });
            }
        });

        observer.observe({
            type: "event",
            durationThreshold: 0,
            buffered: true,
        });
    }

    function logInteraction(interaction) {
        console.log(
            `[${interaction.name}] %c${interaction.value.toFixed(0)}ms (${interaction.rating})`,
            `color: ${RATING_COLORS[interaction.rating] || "inherit"}`
        );

        for (let entry of interaction.entries) {
            console.log(
                `Interaction event type: %c${entry.name}`,
                "font-family: monospace"
            );

            console.log(
                "Interaction target:",
                interaction.attribution.eventTarget
            );

            const adjustedPresentationTime = Math.max(
                entry.processingEnd,
                entry.startTime + entry.duration
            );

            console.table([
                {
                    subPartString: "Input delay",
                    "Time(ms)": Math.round(
                        entry.processingStart - entry.startTime,
                        0
                    ),
                },
                {
                    subPartString: "Processing time",
                    "Time(ms)": Math.round(
                        entry.processingEnd - entry.processingStart,
                        0
                    ),
                },
                {
                    subPartString: "Presentation delay",
                    "Time(ms)": Math.round(
                        adjustedPresentationTime - entry.processingEnd,
                        0
                    ),
                },
            ]);
        }
    }

    function logAllInteractions() {
        onInteraction(logInteraction);
    }

    logAllInteractions();
});

Monitoring and Testing Tools

Multiple tools are available to monitor, measure, and analyze Core Web Vitals performance:

Browser Extensions

Google Tools


Best Practices

Implement these best practices to consistently maintain optimal Core Web Vitals scores:

1. Prioritize User Experience

  • Focus on metrics that directly impact user satisfaction
  • Test across different network conditions and devices
  • Monitor real user metrics using RUM (Real User Monitoring)

2. Optimize Critical Resources

  • Identify and prioritize critical rendering path resources
  • Defer non-critical JavaScript and CSS
  • Use code splitting to reduce initial bundle size

3. Regular Monitoring

  • Set up continuous performance monitoring
  • Use analytics to track Core Web Vitals trends
  • Establish performance budgets for your application

4. Test Before Deploy

  • Run Lighthouse audits during development
  • Test performance improvements in staging environments
  • Include performance checks in your CI/CD pipeline

5. Leverage Content Delivery Networks (CDN)

  • Use CDNs to serve static assets from locations closer to users
  • Enable compression and caching headers
  • Implement edge caching for frequently accessed content

Resources