Back to Blog

Core Web Vitals Optimization: LCP, CLS, INP, and Field Data vs Lab Data

Optimize Core Web Vitals to pass Google's page experience signals: fix Largest Contentful Paint with resource hints and image optimization, eliminate Cumulative Layout Shift with reserved space, improve Interaction to Next Paint with long task splitting, and measure with field data from CrUX.

Viprasol Tech Team
October 8, 2026
13 min read

Core Web Vitals are Google's primary page experience signals and a confirmed ranking factor. But most teams optimize for lab data (Lighthouse scores) while their real users โ€” measured in Chrome User Experience Report (CrUX) field data โ€” are having a different experience.

Lab data tests a single page load under controlled conditions. Field data captures real users on real devices with real network conditions. The gap between the two is where performance problems hide.


The Three Core Web Vitals (2026)

LCP โ€” Largest Contentful Paint
  Measures: How fast does the main content load?
  Target: < 2.5 seconds (Good), 2.5โ€“4s (Needs Improvement), > 4s (Poor)
  What triggers it: Largest image or text block above the fold

CLS โ€” Cumulative Layout Shift
  Measures: How much does content move unexpectedly?
  Target: < 0.1 (Good), 0.1โ€“0.25 (Needs Improvement), > 0.25 (Poor)
  What triggers it: Images without dimensions, injected banners, web fonts

INP โ€” Interaction to Next Paint (replaced FID in 2024)
  Measures: How fast does the page respond to user interactions?
  Target: < 200ms (Good), 200โ€“500ms (Needs Improvement), > 500ms (Poor)
  What triggers it: Long JavaScript tasks blocking the main thread

Optimizing LCP

LCP is usually the hero image or the largest text block. The goal: start loading it as early as possible.

Preload the LCP Resource

<!-- In <head>, before any other resources -->
<!-- Tell the browser to fetch the LCP image immediately -->
<link
  rel="preload"
  as="image"
  href="/images/hero.webp"
  fetchpriority="high"
  imagesrcset="/images/hero-640.webp 640w, /images/hero-1280.webp 1280w, /images/hero.webp 1920w"
  imagesizes="100vw"
/>
// src/app/layout.tsx (Next.js App Router)
// Using Next.js Image component correctly for LCP

import Image from "next/image";

export default function HeroSection() {
  return (
    <section className="relative h-screen">
      <Image
        src="/images/hero.webp"
        alt="Platform overview"
        fill
        // priority = preload + fetchpriority="high"
        // Use for the SINGLE largest above-the-fold image
        priority
        quality={85}
        sizes="100vw"
        style={{ objectFit: "cover" }}
      />
    </section>
  );
}

// Do NOT use priority on every image โ€” only the LCP candidate
// Too many preloads compete and slow each other down

Server-Side Rendering for Text LCP

// src/app/page.tsx
// If your LCP element is text (hero headline), render it server-side
// SSR text: LCP can be as fast as TTFB + render time

export default async function HomePage() {
  // Data fetched server-side โ€” available in HTML immediately
  const heroContent = await getHeroContent();

  return (
    <main>
      {/* LCP candidate: large H1, server-rendered */}
      <h1 className="text-6xl font-bold">
        {heroContent.headline}
      </h1>
      <p className="text-xl mt-4">{heroContent.subheadline}</p>
    </main>
  );
}

Font Optimization

Web fonts often delay LCP โ€” the browser waits for the font before rendering text:

<!-- Preconnect to font origin -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!-- Preload the specific font file used for LCP text -->
<link
  rel="preload"
  as="font"
  type="font/woff2"
  href="/fonts/inter-var.woff2"
  crossorigin
/>
/* font-display: swap โ€” show fallback font immediately, swap when ready */
/* Prevents invisible text (FOIT) that delays LCP */
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2-variations");
  font-display: swap;   /* Show text with fallback, then swap โ€” prevents LCP delay */
  font-weight: 100 900;
}

/* Size-adjust: reduce CLS from font swap */
/* Match the fallback font metrics to reduce layout shift */
@font-face {
  font-family: "Inter-Fallback";
  src: local("Arial");
  size-adjust: 96%;       /* Adjust until the font sizes match approximately */
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Fixing CLS

CLS happens when elements move after initial render. The fix: always reserve space before content loads.

Images Without Dimensions

// BAD: browser doesn't know image dimensions โ†’ layout shift when loaded
<img src="/product.jpg" alt="Product" />

// GOOD: explicit width/height โ†’ browser reserves space
<img src="/product.jpg" alt="Product" width="800" height="600" />

// GOOD: Next.js Image always requires width+height (or fill)
import Image from "next/image";
<Image src="/product.jpg" alt="Product" width={800} height={600} />

Dynamic Content Injection

// BAD: banner appears above content โ†’ everything shifts down
useEffect(() => {
  if (shouldShowBanner) {
    setBannerVisible(true); // Shifts content after render
  }
}, []);

// GOOD: reserve space for the banner from the start
function CookieBanner() {
  const [accepted, setAccepted] = useState<boolean | null>(null);

  return (
    // Always render the container with fixed height
    // Hide content when accepted, but keep the height
    <div
      style={{
        height: accepted === true ? 0 : "60px",
        overflow: "hidden",
        transition: "height 0.3s ease", // Smooth โ€” not a shift
      }}
    >
      {accepted === null && (
        <div className="cookie-banner">
          We use cookies.
          <button onClick={() => setAccepted(true)}>Accept</button>
        </div>
      )}
    </div>
  );
}

Skeleton Screens for Async Content

// src/components/ProductCard.tsx
// Reserve space with skeleton before data loads
"use client";

import { Suspense } from "react";

function ProductCardSkeleton() {
  return (
    <div className="animate-pulse">
      {/* Match exact dimensions of the real card */}
      <div className="h-48 w-full bg-gray-200 rounded" />  {/* Image */}
      <div className="mt-3 h-5 w-3/4 bg-gray-200 rounded" />  {/* Title */}
      <div className="mt-2 h-4 w-1/2 bg-gray-200 rounded" />  {/* Price */}
    </div>
  );
}

async function ProductCard({ productId }: { productId: string }) {
  const product = await getProduct(productId);
  return (
    <div>
      <Image src={product.image} width={400} height={300} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
    </div>
  );
}

export function ProductCardWithSuspense({ productId }: { productId: string }) {
  return (
    <Suspense fallback={<ProductCardSkeleton />}>
      <ProductCard productId={productId} />
    </Suspense>
  );
}

Improving INP

INP (Interaction to Next Paint) measures the time from user interaction to the next visual update. Long JavaScript tasks on the main thread block this.

Break Up Long Tasks

// BAD: 400ms synchronous task blocks the main thread
function processLargeDataset(items: Item[]) {
  const results = items.map(processItem); // Blocks for 400ms
  setResults(results);
}

// GOOD: Yield to the browser between chunks
async function processLargeDatasetAsync(items: Item[]) {
  const CHUNK_SIZE = 100;
  const results: ProcessedItem[] = [];

  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    results.push(...chunk.map(processItem));

    // Yield to browser โ€” allows it to process user input between chunks
    await scheduler.yield(); // Or: await new Promise(r => setTimeout(r, 0))
  }

  setResults(results);
}

// scheduler.yield() is the modern API (Chrome 115+)
// Polyfill for other browsers:
function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Defer Non-Critical JavaScript

// src/app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head />
      <body>
        {children}

        {/* Analytics: load after page is interactive */}
        <Script
          src="https://cdn.example.com/analytics.js"
          strategy="afterInteractive"  // Loads after hydration, doesn't block INP
        />

        {/* Non-critical widgets: load when browser is idle */}
        <Script
          src="https://cdn.example.com/chat-widget.js"
          strategy="lazyOnload"  // Lowest priority
        />
      </body>
    </html>
  );
}

Optimize Event Handlers

// BAD: expensive synchronous filter on every keystroke
function SearchInput() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const q = e.target.value;
    setQuery(q);
    // Runs synchronously on every keypress โ€” bad INP
    const filtered = allItems.filter(item =>
      item.name.toLowerCase().includes(q.toLowerCase())
    );
    setResults(filtered);
  }

  return <input value={query} onChange={handleChange} />;
}

// GOOD: debounce + move filtering to web worker for large datasets
import { useDeferredValue, useTransition } from "react";

function SearchInput({ allItems }: { allItems: Item[] }) {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();
  const deferredQuery = useDeferredValue(query);

  // Filtering happens in a transition โ€” doesn't block typing
  const results = useMemo(
    () => allItems.filter(item =>
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    ),
    [allItems, deferredQuery]
  );

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)} // Instant response
      />
      {isPending && <span>Searching...</span>}
      <ResultsList results={results} />
    </>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Measuring Field Data

Lab data (Lighthouse) doesn't match what real users experience. Use field data:

// src/lib/web-vitals.ts
// Collect real user metrics and send to your analytics
import { onCLS, onINP, onLCP, onFCP, onTTFB, Metric } from "web-vitals";

function sendToAnalytics(metric: Metric) {
  const body = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,   // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    page: window.location.pathname,
    userAgent: navigator.userAgent,
  };

  // Use sendBeacon for reliability (fires even on page unload)
  navigator.sendBeacon("/api/vitals", JSON.stringify(body));
}

// Measure all Core Web Vitals
export function measureWebVitals() {
  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);
  onLCP(sendToAnalytics);
  onFCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}
-- Analyze field data by percentile (p75 is Google's target threshold)
SELECT
  name AS metric,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75,
  PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value) AS p95,
  COUNT(*) FILTER (WHERE rating = 'good') AS good_count,
  COUNT(*) FILTER (WHERE rating = 'needs-improvement') AS ni_count,
  COUNT(*) FILTER (WHERE rating = 'poor') AS poor_count,
  ROUND(100.0 * COUNT(*) FILTER (WHERE rating = 'good') / COUNT(*), 1) AS good_pct
FROM web_vitals
WHERE recorded_at >= NOW() - INTERVAL '7 days'
GROUP BY name
ORDER BY name;

See Also


Working With Viprasol

Core Web Vitals optimization requires measuring real users, not just running Lighthouse. We instrument field data collection, diagnose LCP/CLS/INP failures with real traffic data, and implement targeted fixes โ€” preload hints, font metrics, skeleton screens, task scheduling โ€” that move your p75 scores into the "Good" range.

Web performance engineering โ†’ | Audit your site โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.