Performancecore web vitalsperformancereact

How to Optimize Your React App's Core Web Vitals

A practical guide to improving LCP, CLS, and INP in React and Next.js applications, with real code examples and measurement strategies.

Codolve Team8 min read
Share
How to Optimize Your React App's Core Web Vitals

Google's Core Web Vitals are both a ranking signal and a direct measure of user experience. Poor scores mean lower rankings, higher bounce rates, and lost conversions. React's client-side rendering model introduces specific performance challenges, here's how to address each metric systematically and measure your progress.

The Three Metrics That Matter

Before diving into fixes, it's worth understanding exactly what each metric measures and what "Good" means:

Metric What It Measures Good Threshold Poor Threshold
LCP Load time of the largest visible element < 2.5s > 4.0s
CLS Visual stability, how much content shifts < 0.1 > 0.25
INP Responsiveness to all user interactions < 200ms > 500ms

All three are measured from real user data (field data) via the Chrome User Experience Report (CrUX). Lab tests like Lighthouse are useful for development, but Google ranks you based on what real users experience.

Improving LCP: Make the Main Content Load Fast

LCP measures how quickly the largest above the fold element renders almost always a hero image or a large heading.

Use Server Rendering for Above-the-Fold Content

The single highest leverage fix: render your hero section on the server. Client-rendered content requires JavaScript to parse, execute, and render adding hundreds of milliseconds before anything appears.

In Next.js, Server Components are server-rendered by default:

// app/page.tsx, Server Component by default, no "use client" needed
export default async function HomePage() {
  const heroData = await getHeroContent(); // Server-side fetch

  return (
    <main>
      <HeroSection data={heroData} />
      {/* Client components below the fold are fine */}
    </main>
  );
}

Preload the Hero Image

import Image from "next/image";

// The priority prop tells Next.js to preload this image
<Image
  src="/hero.webp"
  alt="Hero image"
  width={1200}
  height={630}
  priority        // Generates <link rel="preload"> in the document head
  fetchPriority="high"
/>

Never use priority on more than 1–2 images per page. It's for images in the initial viewport only, overusing it defeats the purpose.

Use Modern Image Formats

WebP and AVIF are 30–60% smaller than JPEG at the same visual quality. Next.js's <Image> component handles format conversion automatically when configured:

// next.config.ts
const nextConfig = {
  images: {
    formats: ["image/avif", "image/webp"], // Serve AVIF to supporting browsers, WebP as fallback
  },
};

Preconnect to Critical Third-Party Origins

If your LCP image or font is served from a CDN, preconnecting eliminates DNS lookup and connection time:

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://your-cdn.com" crossOrigin="anonymous" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Reducing CLS: Prevent Layout Shifts

CLS measures the total visual instability users experience while a page loads. Every unexpected layout shift, where content moves after the user starts reading, contributes to CLS.

Always Specify Image Dimensions

The browser can only reserve space for an image if it knows the aspect ratio before the image loads. Without dimensions, every image causes a layout shift when it loads:

// Bad, browser doesn't know the size until the image loads
<img src="/photo.jpg" alt="Photo" />

// Good, browser reserves space immediately
<Image src="/photo.jpg" alt="Photo" width={800} height={450} />

// Also good for responsive images, aspect ratio is inferred
<div className="relative aspect-video">
  <Image src="/photo.jpg" alt="Photo" fill />
</div>

Reserve Space for Dynamic Content

Content that loads asynchronously (comments, ads, user-specific data) shifts existing content if space isn't reserved. Use skeleton screens:

function CommentSection({ postId }: { postId: string }) {
  const { data: comments, isLoading } = useComments(postId);

  if (isLoading) {
    return (
      <div className="space-y-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="h-20 bg-gray-100 rounded-xl animate-pulse" />
        ))}
      </div>
    );
  }

  return <CommentList comments={comments} />;
}

The skeleton maintains the same approximate height as the loaded content, preventing shifts.

Use font-display: swap for Web Fonts

Fonts that block rendering (the default for many web fonts) cause layout shifts when they load and replace system fonts. Swap ensures text is immediately visible:

// next/font handles this automatically
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap", // Show system font immediately, swap when Inter loads
});

Avoid Inserting Content Above Existing Content

Cookie banners, notification bars, and sticky headers that appear after initial render push content down. Instead:

  • Render them at load time with visibility: hidden if they conditionally show
  • Reserve their height with CSS from the start

Improving INP: Keep the Main Thread Responsive

INP (Interaction to Next Paint) measures how long the browser takes to respond to every user interaction, clicks, key presses, taps. The most common cause of poor INP is JavaScript blocking the main thread.

Defer Non-Critical JavaScript

import dynamic from "next/dynamic";

// Chat widget, analytics dashboard, heavy data visualisation , 
// load these after the page is interactive
const ChatWidget = dynamic(() => import("@/components/ChatWidget"), {
  loading: () => null, // No placeholder needed for non-visible components
  ssr: false,          // Don't server-render, load only when needed
});

const DataChart = dynamic(() => import("@/components/DataChart"), {
  loading: () => <div className="h-64 bg-gray-100 animate-pulse rounded-xl" />,
});

Move Expensive Calculations Off the Main Thread

Heavy computations (data processing, filtering large arrays, complex transformations) block user interactions if run on the main thread. Use Web Workers:

// workers/filter.worker.ts
self.addEventListener("message", (event) => {
  const { data, query } = event.data;
  const result = data.filter((item: any) =>
    Object.values(item).some((val) =>
      String(val).toLowerCase().includes(query.toLowerCase())
    )
  );
  self.postMessage(result);
});
// In your component
const workerRef = useRef<Worker | null>(null);

useEffect(() => {
  workerRef.current = new Worker(new URL("../workers/filter.worker.ts", import.meta.url));
  workerRef.current.onmessage = (e) => setFilteredResults(e.data);
  return () => workerRef.current?.terminate();
}, []);

function handleSearch(query: string) {
  workerRef.current?.postMessage({ data: allItems, query });
}

Use startTransition for Non-Urgent Updates

React's startTransition marks state updates as non-urgent, keeping the UI responsive during heavy re-renders:

import { startTransition, useState } from "react";

function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // Urgent, update input immediately

    startTransition(() => {
      setResults(filterItems(allItems, value)); // Non-urgent, can be interrupted
    });
  }

  return (
    <>
      <input value={query} onChange={handleSearch} placeholder="Search..." />
      <ResultsList results={results} />
    </>
  );
}

Profile Before Optimising

Never optimise React rendering performance without profiling first. The React DevTools Profiler shows exactly which components are slow and why:

  1. Open Chrome DevTools → Profiler tab (with React DevTools installed)
  2. Click Record, perform the slow interaction, click Stop
  3. Look for components with high render times, those are your targets

React.memo, useMemo, and useCallback can help, but they add complexity. Only use them where profiling confirms they're needed.

Measuring Your Progress

Use these tools in combination:

  • PageSpeed Insights: both lab (Lighthouse) and field (CrUX) data for your actual URL
  • Google Search Console → Core Web Vitals report, aggregated field data from real users
  • Chrome DevTools → Performance panel, flame charts for diagnosing slow interactions
  • useReportWebVitals: Next.js hook to send Web Vitals to your own analytics
// app/layout.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";

export function WebVitalsTracker() {
  useReportWebVitals((metric) => {
    // Send to your analytics platform
    console.log(metric.name, metric.value);
  });
  return null;
}

Target all three metrics in Google's "Good" range. If your LCP is consistently above 2.5s, that's where to start. If you need help with a comprehensive performance audit, Codolve's web development team can identify and fix the bottlenecks in your application.

Frequently Asked Questions

How do Core Web Vitals affect my Google rankings?

Core Web Vitals are a ranking signal in Google's Page Experience algorithm. They're a tiebreaker for pages with similar relevance, a slower competitor may rank lower even with equivalent content quality. For competitive keywords, improving from "Needs Improvement" to "Good" can deliver meaningful ranking gains.

Should I prioritise LCP, CLS, or INP first?

Start with whichever has the most room for improvement. Check your Search Console Core Web Vitals report, it shows which pages are failing and which metric is the problem. LCP is often the highest-impact fix for most content sites. INP is most important for interactive applications.

Do Lighthouse scores match real user data?

Not always. Lighthouse runs in a controlled environment on simulated hardware. Real users have different devices, connections, and browser extensions. Always prioritise the field data from Search Console over Lighthouse scores.

How quickly do Core Web Vitals improvements reflect in rankings?

Google processes CrUX data over a 28-day rolling window. You need to maintain improvements for roughly a month before the field data fully reflects your changes. Expect to wait 4–8 weeks after fixing issues to see ranking impact.

Is server-side rendering always better for performance?

SSR improves LCP significantly for content-heavy pages, but it adds server processing time. For highly interactive applications with personalised data, a well-optimised CSR with proper loading states and skeleton screens can perform comparably. Use SSR where possible for public-facing, content-heavy pages; use CSR judiciously for authenticated, interactive dashboards.

Tags

#core web vitals#performance#react#nextjs#lcp#cls#inp#seo
Share
userImage1userImage2userImage3

Build impactful digital products

Ready to Start Your Next Big Project ?

Contact Us