DEV Community

Munna Thakur
Munna Thakur

Posted on

Web Vitals in React: The Complete Guide to Measuring and Optimizing Performance (2026)

Who is this for? React developers who want to understand why their app feels slow, what Google actually measures, and how to fix it — with real code examples and production-level techniques.


Table of Contents

  1. What Are Web Vitals?
  2. The Three Core Metrics Explained
  3. How to Measure Web Vitals in React
  4. Why React CSR Apps Have Poor LCP
  5. React Fiber: How It Improves Web Vitals Internally
  6. Fixing LCP in Production
  7. Fixing CLS in Production
  8. Fixing INP in Production
  9. Code Splitting: What It Is and Why Users Love It
  10. Image Optimization: Why WebP Matters
  11. The Production Debug Workflow
  12. Interview-Ready Answers
  13. Summary Cheatsheet

1. What Are Web Vitals?

Web Vitals are a set of performance metrics introduced by Google to measure the real user experience of a website — not just technical speed numbers, but how a site actually feels to someone using it.

Before Web Vitals, developers would measure things like "page load time" — a single number that didn't tell you much about whether the page was actually usable. Web Vitals break that down into three specific human experiences:

  • Can I see the main content quickly? → LCP
  • Does the page jump around unexpectedly? → CLS
  • Does the page respond when I click something? → INP

Why Should You Care?

Because Web Vitals directly affect:

Factor Impact
Google SEO ranking Google uses Core Web Vitals as a ranking signal
Bounce rate A 1-second delay in load time increases bounce rate by ~32%
Conversion rate Faster pages convert better — this is well-documented
User trust A slow, jumpy page feels broken even if it technically works

💡 Simple Restaurant Analogy: Imagine a restaurant where the food takes 30 minutes (LCP), the table wobbles whenever a dish arrives (CLS), and the waiter takes 5 minutes to respond when you wave at them (INP). The food might be great, but the experience is terrible.


2. The Three Core Metrics Explained

LCP — Largest Contentful Paint

What it measures: How quickly the largest visible element on the page renders inside the viewport.

This is usually your hero image, a main heading, a product photo, or a large text block. Whatever takes up the most visual space above the fold.

Good:   < 2.5 seconds  ✅
Needs improvement: 2.5s – 4.0s  ⚠️
Poor:   > 4.0 seconds  ❌
Enter fullscreen mode Exit fullscreen mode

What affects LCP:

  • Large, unoptimized images
  • Slow server response times
  • Render-blocking JavaScript (very common in React CSR)
  • Large CSS files that delay rendering
// ❌ This image has no dimensions and is not optimized
<img src="hero.jpg" />

// ✅ Better — explicit dimensions + WebP + preload
<link rel="preload" as="image" href="/hero.webp" />
<img src="/hero.webp" width="1200" height="600" alt="Hero" />
Enter fullscreen mode Exit fullscreen mode

CLS — Cumulative Layout Shift

What it measures: How much the visible content unexpectedly moves around during the page lifecycle. It's a score (not a time), calculated by measuring the total area shifted multiplied by the distance shifted.

Good:   < 0.1   ✅
Needs improvement: 0.1 – 0.25  ⚠️
Poor:   > 0.25  ❌
Enter fullscreen mode Exit fullscreen mode

You've experienced bad CLS when:

  • You're about to tap a button, and an ad loads above it, pushing the button down
  • Text reflows suddenly after a web font loads
  • An image appears without reserved space, pushing everything below it
// ❌ CLS problem: image has no dimensions
// Browser doesn't know how much space to reserve
<img src="/banner.jpg" />

// ✅ Fix: always specify width and height
// Browser reserves the exact space before the image loads
<img src="/banner.webp" width="800" height="400" alt="Banner" />
Enter fullscreen mode Exit fullscreen mode

INP — Interaction to Next Paint

What it measures: The time from when a user interacts with the page (click, tap, keyboard input) to when the browser visually responds. This replaced FID (First Input Delay) in 2024.

Good:   < 200ms   ✅
Needs improvement: 200ms – 500ms  ⚠️
Poor:   > 500ms   ❌
Enter fullscreen mode Exit fullscreen mode

Bad INP feels like: you click a button and nothing seems to happen for half a second. The page feels sluggish or unresponsive.

The main cause in React apps: Heavy JavaScript running on the main thread when a user interaction happens.

// ❌ Heavy synchronous work blocks the browser's ability to respond
function SearchResults({ query }) {
  const results = expensiveFilter(allData, query); // blocks main thread
  return <List data={results} />;
}

// ✅ Defer non-urgent work using useTransition
function SearchResults({ query }) {
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);

  const handleSearch = (value) => {
    startTransition(() => {
      // This update runs at lower priority — won't block user interaction
      setResults(expensiveFilter(allData, value));
    });
  };

  return (
    <>
      {isPending && <Spinner />}
      <List data={results} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. How to Measure Web Vitals in React

There are three layers of measurement you should know: in-code, browser tools, and real-user monitoring.

Layer 1 — In-Code Measurement (Real User Data)

Install the official web-vitals package:

npm install web-vitals
Enter fullscreen mode Exit fullscreen mode

In a Create React App project

CRA already gives you reportWebVitals.js. Just update your index.js:

// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

// Pass console.log to see metrics in the browser console (dev only)
reportWebVitals(console.log);
Enter fullscreen mode Exit fullscreen mode

Sample console output:

{name: 'LCP', value: 2100, rating: 'good', ...}
{name: 'CLS', value: 0.03, rating: 'good', ...}
{name: 'INP', value: 180, rating: 'good', ...}
Enter fullscreen mode Exit fullscreen mode

In a Vite project (manual setup)

// main.jsx
import { getCLS, getLCP, getINP, getFCP, getTTFB } from 'web-vitals';

// Development: log to console
getCLS(console.log);
getLCP(console.log);
getINP(console.log);
getFCP(console.log);
getTTFB(console.log);
Enter fullscreen mode Exit fullscreen mode

Production: Send to Your Analytics Server

Never just console.log in production. Send the data to your backend:

import { getCLS, getLCP, getINP } from 'web-vitals';

function sendToAnalytics(metric) {
  fetch('/api/vitals', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    // Use `sendBeacon` for reliability on page unload
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,  // 'good' | 'needs-improvement' | 'poor'
      id: metric.id,
      page: window.location.pathname,
    }),
  });
}

getCLS(sendToAnalytics);
getLCP(sendToAnalytics);
getINP(sendToAnalytics);
Enter fullscreen mode Exit fullscreen mode

Layer 2 — Browser Dev Tools

Google Lighthouse (built into Chrome)

Right-click page → Inspect → Lighthouse tab → Analyze page load
Enter fullscreen mode Exit fullscreen mode

This gives you a full report with scores, specific issues, and fix recommendations.

Chrome DevTools Performance Panel

Inspect → Performance tab → Click Record → Reload page → Stop recording
Enter fullscreen mode Exit fullscreen mode

Look for:

  • Long Tasks (red blocks on the main thread)
  • LCP marker in the timeline
  • Layout Shift regions

PageSpeed Insights — tests your live URL with real Chrome data:

https://pagespeed.web.dev
Enter fullscreen mode Exit fullscreen mode

Layer 3 — Production Monitoring Tools

Tool What It Does
Vercel Analytics Built-in Web Vitals from real users if you deploy on Vercel
Sentry Performance monitoring + error tracking together
Datadog Enterprise-level RUM (Real User Monitoring)
Google Search Console Shows Core Web Vitals for your indexed pages

4. Why React CSR Apps Have Poor LCP

This is one of the most important concepts for any React developer to understand, and it comes up in senior interviews regularly.

The CSR Rendering Pipeline

A typical React app created with CRA or Vite (Client-Side Rendering) works like this:

User navigates to your site
        ↓
Browser downloads HTML from server
(The HTML is basically empty: <div id="root"></div>)
        ↓
Browser downloads your JavaScript bundle (main.js)
[This could be 500KB–2MB]
        ↓
Browser parses and executes the JavaScript
        ↓
React runs, creates the Virtual DOM
        ↓
ReactDOM commits to the real DOM
        ↓
Browser paints the screen
        ↓
LCP is measured HERE 📍
Enter fullscreen mode Exit fullscreen mode

The problem is obvious once you see it: nothing can render until all that JavaScript downloads, parses, and runs. The user stares at a blank screen during all of that.

The SSR / Next.js Pipeline

User navigates to your site
        ↓
Server renders the complete HTML
(The HTML already contains your h1, images, content)
        ↓
Browser receives full HTML and paints immediately
        ↓
LCP is measured HERE 📍 (much earlier!)
        ↓
React "hydrates" the HTML in the background
(attaches event listeners, makes it interactive)
Enter fullscreen mode Exit fullscreen mode

The Numbers in Practice

React CSR (typical):
  HTML download:    20ms
  JS bundle (1MB):  600ms
  JS parse/execute: 300ms
  React render:     150ms
  ─────────────────────
  LCP ≈ 1070ms+ (and that's on a fast connection)

Next.js SSR (same app):
  Server render:    100ms
  HTML download:    50ms
  Browser paint:    immediate
  ─────────────────────
  LCP ≈ 150ms
Enter fullscreen mode Exit fullscreen mode

The Key Difference

React CSR Next.js SSR
Initial HTML <div id="root"></div> (empty) Full page content
Where rendering happens Browser (client) Server
LCP Slow Fast
SEO Weak (crawlers see empty HTML) Strong
Interactivity Immediate after hydration After hydration

⚠️ Important nuance: SSR doesn't automatically fix everything. If your server is slow, if you have large unoptimized images, or if your hydration bundle is massive, you can still have poor LCP even with SSR.


5. React Fiber: How It Improves Web Vitals Internally

React Fiber is React's internal rendering engine (introduced in React 16). Most developers use it every day without realizing it exists. Understanding it explains why useTransition and useDeferredValue work the way they do.

The Problem Fiber Solved

Before Fiber, React's rendering was synchronous and uninterruptible. When React started rendering a component tree, it couldn't stop until it was done — even if the user was trying to click a button.

Large state update triggered
        ↓
React starts rendering the full component tree
        ↓
Browser is blocked (can't respond to user input)
        ↓
React finishes rendering
        ↓
DOM updated
        ↓
Browser finally responds to the user's click
Enter fullscreen mode Exit fullscreen mode

On large apps, this blocking period could be 500ms+. That's terrible INP.

How Fiber Fixed It: Incremental Rendering

Fiber breaks rendering work into small units called fibers (one per component). The scheduler can pause between units, check if something more urgent has arrived (like a user click), and handle that first.

Large state update triggered
        ↓
Fiber starts rendering: processes Navbar fiber
        ↓
Yields to browser: "anything urgent?"
        ↓
User click detected! → Handle click immediately
        ↓
Resume rendering: processes Sidebar fiber
        ↓
Yields to browser again
        ↓
Continue until done
Enter fullscreen mode Exit fullscreen mode

This is why React feels responsive even during complex updates.

The Two Phases of Fiber Rendering

Phase 1 — Render Phase (Reconciliation)

React builds a new fiber tree and calculates what changed. This phase is:

  • Interruptible — can be paused and resumed
  • Async — can be spread across multiple frames
  • Side-effect free — no DOM changes yet

Phase 2 — Commit Phase

React applies the calculated changes to the actual DOM. This phase is:

  • Synchronous — cannot be interrupted
  • Fast — all calculations are already done
  • Where DOM mutations, layout effects, and paint happen

Priority Levels in Fiber's Scheduler

Fiber assigns priority to every update:

Priority Example
Immediate Error boundaries, forced sync renders
User Blocking Text input, button clicks, direct interactions
Normal Data fetches, state updates from effects
Low Analytics, logging
Idle Prefetching, precomputing

This is what powers useTransition:

// Without useTransition: typing + filtering compete for same priority
const [query, setQuery] = useState('');
const filtered = data.filter(item => item.includes(query));

// With useTransition: typing is User Blocking, filtering is Normal
// Typing ALWAYS wins — the input never freezes
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [filtered, setFiltered] = useState(data);

const handleChange = (e) => {
  const value = e.target.value;
  setQuery(value); // High priority — updates input immediately

  startTransition(() => {
    setFiltered(data.filter(item => item.includes(value))); // Lower priority
  });
};
Enter fullscreen mode Exit fullscreen mode

useDeferredValue — Fiber's Other Superpower

// Search input stays responsive even while results are computing
function SearchPage() {
  const [query, setQuery] = useState('');

  // This value "lags behind" intentionally — old results stay visible
  // while new results are being computed in the background
  const deferredQuery = useDeferredValue(query);

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* Results use the deferred value — won't block the input */}
      <SearchResults query={deferredQuery} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fiber's Direct Impact on Web Vitals

Metric How Fiber Helps
INP Priority scheduling prevents user interactions from being blocked by non-urgent rendering work
LCP Concurrent features allow streaming SSR and progressive rendering
CLS Predictable rendering phases reduce unexpected layout changes

6. Fixing LCP in Production

LCP problems in React apps almost always fall into one of three categories: large JavaScript bundles, unoptimized images, or missing server rendering.

Fix 1 — Analyze and Reduce Your Bundle Size

First, see the problem:

# Build the app first
npm run build

# Then analyze what's inside
npx source-map-explorer 'build/static/js/*.js'
# or
npx webpack-bundle-analyzer build/bundle-stats.json
Enter fullscreen mode Exit fullscreen mode

Common culprits:

  • Importing entire libraries when you only need one function
  • Duplicate dependencies (two versions of the same package)
  • Libraries that should be code-split but aren't
// ❌ Imports the entire lodash library (70KB+)
import _ from 'lodash';
const result = _.debounce(fn, 300);

// ✅ Import only what you need (1KB)
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);

// ✅ Even better: use lodash-es for tree shaking
import { debounce } from 'lodash-es';
Enter fullscreen mode Exit fullscreen mode

Fix 2 — Preload Your Hero Image

<!-- In your public/index.html — load hero image before JS even parses -->
<link rel="preload" as="image" href="/hero.webp" />
Enter fullscreen mode Exit fullscreen mode

This is one of the highest-impact single-line fixes for LCP. It tells the browser to start downloading the image as early as possible, instead of waiting to discover it while parsing your React components.

Fix 3 — Code Split Everything That Isn't Needed Immediately

import React, { Suspense, lazy } from 'react';

// These routes only load their JS when the user navigates to them
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));

function App() {
  return (
    <Router>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/profile" element={<Profile />} />
        </Routes>
      </Suspense>
    </Router>
  );
}
Enter fullscreen mode Exit fullscreen mode

Before code splitting: main.js = 2.1MB
After code splitting: main.js = 280KB + lazy chunks loaded on demand

Fix 4 — Move to SSR (The Nuclear Option for LCP)

If LCP is consistently poor and your app is content-heavy (blogs, e-commerce, landing pages), the most impactful fix is Server-Side Rendering.

# Migrate to Next.js
npx create-next-app@latest my-app
Enter fullscreen mode Exit fullscreen mode

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

// pages/index.js — rendered on the server, HTML arrives ready to paint
export default function HomePage({ posts }) {
  return (
    <main>
      <h1>Latest Posts</h1>  {/* This is in the HTML before JS runs */}
      {posts.map(post => <PostCard key={post.id} post={post} />)}
    </main>
  );
}

export async function getServerSideProps() {
  const posts = await fetchPosts();
  return { props: { posts } };
}
Enter fullscreen mode Exit fullscreen mode

7. Fixing CLS in Production

CLS is usually the most fixable metric once you know the rules.

Rule 1 — Always Set Image Dimensions

// ❌ Browser doesn't know the size, reserves 0px, causes shift when image loads
<img src="/product.webp" alt="Product" />

// ✅ Browser reserves exact space before image downloads
<img src="/product.webp" alt="Product" width="400" height="300" />
Enter fullscreen mode Exit fullscreen mode

Or use the CSS aspect-ratio approach:

.image-container {
  aspect-ratio: 4 / 3;  /* Reserves the correct space */
  width: 100%;
}

.image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
Enter fullscreen mode Exit fullscreen mode

Rule 2 — Use Skeleton Screens for Dynamic Content

function ProductCard({ productId }) {
  const { data, isLoading } = useFetchProduct(productId);

  // ✅ Reserve the exact same space as the real content
  if (isLoading) {
    return (
      <div className="product-card skeleton" style={{ height: '320px' }}>
        <div className="skeleton-image" style={{ height: '200px' }} />
        <div className="skeleton-title" style={{ height: '20px', marginTop: '12px' }} />
        <div className="skeleton-price" style={{ height: '16px', marginTop: '8px' }} />
      </div>
    );
  }

  return (
    <div className="product-card" style={{ height: '320px' }}>
      <img src={data.image} width="400" height="200" alt={data.name} />
      <h2>{data.name}</h2>
      <p>{data.price}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Rule 3 — Never Inject Content Above Existing Content

// ❌ Injecting a banner at the top after load pushes everything down
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
  setShowBanner(true); // This causes layout shift!
}, []);

// ✅ Reserve space for the banner from the start
const [bannerContent, setBannerContent] = useState(null);
useEffect(() => {
  setBannerContent('Free shipping on orders over $50!');
}, []);

return (
  // This div always exists — content slot is reserved, no shift
  <div style={{ minHeight: '40px' }}>
    {bannerContent && <Banner>{bannerContent}</Banner>}
  </div>
);
Enter fullscreen mode Exit fullscreen mode

Rule 4 — Use font-display: optional for Web Fonts

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  /* 'optional' uses fallback if font doesn't load quickly */
  /* prevents text from reflowing when the custom font loads */
  font-display: optional;
}
Enter fullscreen mode Exit fullscreen mode

8. Fixing INP in Production

INP is the hardest metric to fix because it requires understanding what's happening on React's main thread during user interactions.

Fix 1 — Memoize Expensive Calculations

// ❌ Recalculates on every render, even when data hasn't changed
function Dashboard({ orders }) {
  const revenue = orders.reduce((sum, o) => sum + o.amount, 0);
  const topProducts = orders
    .sort((a, b) => b.amount - a.amount)
    .slice(0, 10);

  return <StatsDisplay revenue={revenue} topProducts={topProducts} />;
}

// ✅ Only recalculates when orders actually changes
function Dashboard({ orders }) {
  const revenue = useMemo(
    () => orders.reduce((sum, o) => sum + o.amount, 0),
    [orders]
  );

  const topProducts = useMemo(
    () => orders.sort((a, b) => b.amount - a.amount).slice(0, 10),
    [orders]
  );

  return <StatsDisplay revenue={revenue} topProducts={topProducts} />;
}
Enter fullscreen mode Exit fullscreen mode

Fix 2 — Prevent Child Component Re-renders

// ❌ handleSubmit is recreated every render
// → CartItem sees a new prop → rerenders even when nothing changed
function Cart({ items }) {
  const handleRemove = (id) => removeItem(id);
  return items.map(item => <CartItem key={item.id} onRemove={handleRemove} />);
}

// ✅ Stable function reference → CartItem only rerenders when items change
const CartItem = React.memo(function CartItem({ item, onRemove }) {
  return (
    <div>
      <span>{item.name}</span>
      <button onClick={() => onRemove(item.id)}>Remove</button>
    </div>
  );
});

function Cart({ items }) {
  const handleRemove = useCallback((id) => removeItem(id), []);
  return items.map(item => (
    <CartItem key={item.id} item={item} onRemove={handleRemove} />
  ));
}
Enter fullscreen mode Exit fullscreen mode

Fix 3 — Virtualize Long Lists

Rendering 10,000 list items to the DOM is always going to hurt INP. With virtualization, only the ~15 items currently visible in the viewport are rendered as real DOM nodes.

import { FixedSizeList as List } from 'react-window';

// ❌ Renders all 10,000 rows to the DOM at once
function ProductList({ products }) {
  return (
    <ul>
      {products.map(p => <ProductRow key={p.id} product={p} />)}
    </ul>
  );
}

// ✅ Only renders ~15 visible rows at any time
function ProductList({ products }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductRow product={products[index]} />
    </div>
  );

  return (
    <List
      height={600}        // Height of the scrollable container
      itemCount={products.length}  // Total number of items
      itemSize={72}       // Height of each row in pixels
      width="100%"
    >
      {Row}
    </List>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fix 4 — Debounce Expensive Input Handlers

import { useMemo } from 'react';
import debounce from 'lodash/debounce';

function SearchBox({ onSearch }) {
  // ❌ Fires search API on every single keystroke
  const handleChange = (e) => onSearch(e.target.value);

  // ✅ Waits 300ms after user stops typing before firing
  const handleChange = useMemo(
    () => debounce((e) => onSearch(e.target.value), 300),
    [onSearch]
  );

  return <input onChange={handleChange} placeholder="Search..." />;
}
Enter fullscreen mode Exit fullscreen mode

9. Code Splitting: What It Is and Why Users Love It

Without code splitting, your entire app ships as one JavaScript file. Even if a user only visits the homepage, they download the code for the dashboard, settings, profile pages, and everything else.

Code splitting fixes this by breaking your bundle into route-level (or component-level) chunks that only load when needed.

Before vs After

Without Code Splitting:
  main.js = 2.3MB
  User visits homepage → downloads 2.3MB → 3.2s load time

With Code Splitting:
  main.js = 280KB     (homepage + shared code)
  dashboard.js = 450KB (only loads when user goes to /dashboard)
  settings.js = 180KB  (only loads when user goes to /settings)

  User visits homepage → downloads 280KB → 0.8s load time ⚡
Enter fullscreen mode Exit fullscreen mode

How It Works Internally

When Webpack (or Vite's Rollup) sees a dynamic import(), it automatically creates a separate chunk file at build time:

// Static import — always bundled into main.js
import Dashboard from './Dashboard';

// Dynamic import — creates dashboard.chunk.js as a separate file
const Dashboard = lazy(() => import('./Dashboard'));
Enter fullscreen mode Exit fullscreen mode

At runtime, when React first needs to render <Dashboard />, it triggers the dynamic import, which makes an HTTP request for dashboard.chunk.js, downloads it, and renders the component.

Prefetching Chunks the User Will Likely Need

// Load the chunk immediately (user is about to need it)
const Dashboard = lazy(() => import(/* webpackPreload: true */ './Dashboard'));

// Load the chunk in the background during idle time (user might need it)
const Settings = lazy(() => import(/* webpackPrefetch: true */ './Settings'));
Enter fullscreen mode Exit fullscreen mode

React.lazy + Suspense: Full Pattern

import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';

// Route-level code splitting
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserProfile = lazy(() => import('./pages/UserProfile'));

// A reusable loading fallback that matches your page layout
function PageLoader() {
  return (
    <div style={{ padding: '40px', textAlign: 'center' }}>
      <div className="spinner" aria-label="Loading page..." />
    </div>
  );
}

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/profile/:id" element={<UserProfile />} />
      </Routes>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

10. Image Optimization: Why WebP Matters

Images are often the largest resource on a page and the single biggest contributor to poor LCP. Choosing the right format makes a massive difference.

Format Comparison

Format Size Transparency Animation Best For
JPEG Large Photos (old standard)
PNG Very large Logos, screenshots
GIF Huge (Avoid)
WebP Small Everything modern
AVIF Smallest Next-gen (less browser support)

WebP images are typically 25–70% smaller than JPEG/PNG at the same visual quality.

The Right Way to Use WebP in React

// ✅ Use <picture> for progressive enhancement
// Modern browsers use WebP, older browsers fall back to JPEG
function HeroImage() {
  return (
    <picture>
      <source srcSet="/hero.avif" type="image/avif" />
      <source srcSet="/hero.webp" type="image/webp" />
      <img
        src="/hero.jpg"
        alt="Hero banner"
        width="1200"
        height="600"
        loading="eager"  // Hero image should load immediately
      />
    </picture>
  );
}

// For below-the-fold images, use lazy loading
function ProductImage({ src, alt }) {
  return (
    <picture>
      <source srcSet={src.replace('.jpg', '.webp')} type="image/webp" />
      <img
        src={src}
        alt={alt}
        width="400"
        height="300"
        loading="lazy"    // Browser defers loading until near viewport
        decoding="async"  // Doesn't block main thread while decoding
      />
    </picture>
  );
}
Enter fullscreen mode Exit fullscreen mode

Preloading the Hero Image

The hero image is almost always your LCP element. Tell the browser to fetch it as early as possible:

<!-- public/index.html — runs before any JavaScript -->
<link rel="preload" as="image" href="/hero.webp" type="image/webp" />
Enter fullscreen mode Exit fullscreen mode

This is often a 500ms+ improvement for LCP because the image starts downloading while the HTML is still being parsed, before React even runs.


11. The Production Debug Workflow

When someone reports that the app "feels slow" in production, here's the systematic process to find and fix the problem:

Step 1 — Get Real Numbers

# Test against production URL (or staging)
# Use PageSpeed Insights: https://pagespeed.web.dev
Enter fullscreen mode Exit fullscreen mode

Don't debug against localhost — your machine is much faster than real users' devices. Test on Slow 4G throttling in Chrome DevTools to simulate reality.

Step 2 — Identify Which Metric is Failing

Failing Metric Likely Cause Section to Read
LCP > 2.5s Large JS bundle, slow images, CSR Section 6
CLS > 0.1 Missing image dimensions, dynamic injection Section 7
INP > 200ms Heavy JS, too many re-renders Section 8

Step 3 — Profile the Specific Problem

For LCP (bundle size issue):

npm run build
npx source-map-explorer 'build/static/js/*.js'
Enter fullscreen mode Exit fullscreen mode

Look for: unexpectedly large libraries, duplicate code, pages that don't need to be in the main bundle.

For INP (interaction lag):

Chrome DevTools → Performance tab → Record
→ Click the slow button
→ Stop recording
→ Look for Long Tasks (red blocks) on the Main thread
→ Click the task to see which functions caused it
Enter fullscreen mode Exit fullscreen mode

Step 4 — Apply Targeted Fixes

// Common fixes by category:

// 1. Bundle size → code split routes
const HeavyPage = lazy(() => import('./HeavyPage'));

// 2. Re-renders → memoize components and callbacks  
const MyComponent = React.memo(Component);
const handleClick = useCallback(() => { /* ... */ }, [deps]);

// 3. Expensive calculations → memoize values
const result = useMemo(() => expensiveCalculation(data), [data]);

// 4. Long lists → virtualize
import { FixedSizeList } from 'react-window';

// 5. Slow interactions → defer non-urgent work
startTransition(() => setHeavyState(newValue));
Enter fullscreen mode Exit fullscreen mode

Step 5 — Verify the Fix

After applying a fix, measure again with the same tool and compare. Don't guess — measure.


12. Interview-Ready Answers

"What are Core Web Vitals?"

Core Web Vitals are three Google performance metrics that measure user experience: LCP (how quickly the largest content loads), CLS (how much the layout shifts unexpectedly), and INP (how fast the UI responds to user interactions). They affect both user experience and Google search rankings.


"Why does a React CSR app have poor LCP?"

In a Client-Side Rendered React app, the initial HTML is essentially empty — just a <div id="root">. The browser must download, parse, and execute the entire JavaScript bundle before React can render anything visible. This means LCP is delayed by the entire JS load + execution time. In contrast, SSR frameworks like Next.js render HTML on the server and send complete content to the browser, so the largest content element is visible immediately.


"What is React Fiber and how does it improve performance?"

React Fiber is React's internal rendering engine that breaks rendering work into small interruptible units. Unlike the old synchronous renderer, Fiber can pause rendering between component units, check if something more urgent (like a user click) needs to happen, handle that first, and then resume. It also introduces a scheduler with priority levels, which powers concurrent features like useTransition that defer non-urgent updates so they don't block user interactions. This directly improves INP.


"What is code splitting and when should you use it?"

Code splitting breaks a large JavaScript bundle into smaller chunks that load on demand. It's implemented in React using React.lazy() with dynamic import(). You should use it for route-level splitting (each page loads its own JS), feature-level splitting (loading heavy components like charts or editors only when needed), and any component that's not visible on initial load. The benefit is a much smaller initial bundle, which directly improves LCP and Time to Interactive.


"How do you fix CLS in a React app?"

CLS is fixed by ensuring space is always reserved for content before it loads. The main techniques are: always specifying width and height on images, using skeleton screens that match the dimensions of the real content, never injecting content above existing content after page load, and using font-display: optional or swap for web fonts to prevent text reflow.


13. Summary Cheatsheet

Metric Quick Reference

LCP (Largest Contentful Paint)
  Target:  < 2.5s
  Fix with: Code splitting, image preloading, WebP images, SSR

CLS (Cumulative Layout Shift)
  Target:  < 0.1
  Fix with: Image dimensions, skeleton screens, reserved space

INP (Interaction to Next Paint)
  Target:  < 200ms
  Fix with: useTransition, useMemo, React.memo, virtualization
Enter fullscreen mode Exit fullscreen mode

The Complete Optimization Toolkit

// 1. Measure
import { getCLS, getLCP, getINP } from 'web-vitals';

// 2. Code Split Routes
const Page = lazy(() => import('./Page'));

// 3. Memoize Components
const Component = React.memo(MyComponent);

// 4. Memoize Values
const value = useMemo(() => compute(data), [data]);

// 5. Stable Callbacks
const fn = useCallback(() => doSomething(), []);

// 6. Defer Non-Urgent Updates
startTransition(() => setHeavyState(newValue));

// 7. Defer Input-Linked State
const deferred = useDeferredValue(searchQuery);

// 8. Virtualize Lists
import { FixedSizeList } from 'react-window';

// 9. Optimize Images
<img src="/image.webp" width="800" height="400" loading="lazy" />

// 10. Preload Hero
<link rel="preload" as="image" href="/hero.webp" />
Enter fullscreen mode Exit fullscreen mode

Decision Guide

App feels slow to load?
  → Check LCP → probably bundle size or images
  → Add code splitting and use WebP + preload

Page elements jump around?
  → Check CLS → probably missing image dimensions
  → Add width/height to all images, use skeleton screens

Clicks feel delayed or unresponsive?
  → Check INP → probably heavy JS on main thread
  → Add useTransition, memoize components, virtualize lists

App is content-heavy (blog, e-commerce)?
  → Consider migrating to Next.js for SSR
  → LCP improvement is often dramatic
Enter fullscreen mode Exit fullscreen mode

Further Reading


Found this helpful? Drop a ❤️ and share it with a React dev who's struggling with performance. Questions or things I missed? Let me know in the comments.

react #webperformance #javascript #corevitals #frontend #webdev #nextjs #optimization

Top comments (0)