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
- What Are Web Vitals?
- The Three Core Metrics Explained
- How to Measure Web Vitals in React
- Why React CSR Apps Have Poor LCP
- React Fiber: How It Improves Web Vitals Internally
- Fixing LCP in Production
- Fixing CLS in Production
- Fixing INP in Production
- Code Splitting: What It Is and Why Users Love It
- Image Optimization: Why WebP Matters
- The Production Debug Workflow
- Interview-Ready Answers
- 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 ❌
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" />
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 ❌
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" />
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 ❌
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} />
</>
);
}
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
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);
Sample console output:
{name: 'LCP', value: 2100, rating: 'good', ...}
{name: 'CLS', value: 0.03, rating: 'good', ...}
{name: 'INP', value: 180, rating: 'good', ...}
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);
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);
Layer 2 — Browser Dev Tools
Google Lighthouse (built into Chrome)
Right-click page → Inspect → Lighthouse tab → Analyze page load
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
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
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 📍
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)
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
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
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
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
});
};
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} />
</>
);
}
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
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';
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" />
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>
);
}
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
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 } };
}
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" />
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;
}
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>
);
}
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>
);
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;
}
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} />;
}
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} />
));
}
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>
);
}
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..." />;
}
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 ⚡
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'));
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'));
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>
);
}
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>
);
}
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" />
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
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'
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
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));
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
useTransitionthat 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 dynamicimport(). 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
widthandheighton images, using skeleton screens that match the dimensions of the real content, never injecting content above existing content after page load, and usingfont-display: optionalorswapfor 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
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" />
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
Further Reading
- 📘 web.dev — Core Web Vitals
- ⚡ React Docs — Performance
- 🔧 React Docs — Concurrent Features
- 📦 web-vitals npm package
- 🧪 PageSpeed Insights
- 🪟 react-window (virtualization)
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.
Top comments (0)