Optimizing Web Vitals in React: Performance Best Practices
React applications often struggle with Core Web Vitals due to large bundle sizes and client-side rendering overhead. This guide shows you how to build fast React apps that pass Core Web Vitals assessments.
Why React Apps Struggle with Web Vitals
React applications face unique performance challenges:
1. Large Bundle Sizes
- React itself: ~40KB gzipped
- React DOM: ~130KB gzipped
- Application code and dependencies
- Often resulting in 500KB+ initial loads
2. Client-Side Rendering
- Blank screen until JavaScript loads
- Delayed LCP (Largest Contentful Paint)
- Poor First Contentful Paint (FCP)
3. JavaScript-Heavy Interactions
- Event handlers for everything
- Virtual DOM reconciliation
- Can impact INP (Interaction to Next Paint)
4. Dynamic Content Loading
- Components mounting/unmounting
- State changes causing layout shifts
- Affecting CLS (Cumulative Layout Shift)
Measuring Web Vitals in React
Using web-vitals Library
First, install the official library:
npm install web-vitals
Basic Implementation
Create a custom hook for monitoring:
// hooks/useWebVitals.js
import {useEffect} from 'react';
import {onCLS, onINP, onLCP} from 'web-vitals';
export function useWebVitals() {
useEffect(() => {
function sendToAnalytics(metric) {
// Send to your analytics
console.log(metric);
// Example: Google Analytics
if (window.gtag) {
window.gtag('event', metric.name, {
value: Math.round(metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
non_interaction: true,
});
}
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
}, []);
}
Usage in App Component
// App.jsx
import {useWebVitals} from './hooks/useWebVitals';
function App() {
// Monitor Web Vitals
useWebVitals();
return (
<div className="App">
<Header />
<Main />
<Footer />
</div>
);
}
export default App;
Optimizing Largest Contentful Paint (LCP)
1. Code Splitting with React.lazy()
Split your bundle to reduce initial load:
import {lazy, Suspense} from 'react';
// Instead of:
// import Dashboard from './Dashboard';
// Use lazy loading:
const Dashboard = lazy(() => import('./Dashboard'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Dashboard />
</Suspense>
);
}
2. Route-Based Code Splitting
Split code at route boundaries:
// App.jsx
import {lazy, Suspense} from 'react';
import {BrowserRouter, Routes, Route} from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
3. Optimize Images
Use modern image loading techniques:
// components/OptimizedImage.jsx
function OptimizedImage({src, alt, width, height}) {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
// Prevent layout shift with explicit dimensions
style={{
aspectRatio: `${width} / ${height}`,
objectFit: 'cover'
}}
/>
);
}
4. Preload Critical Resources
// index.html or App.jsx
function App() {
useEffect(() => {
// Preload hero image
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = '/hero-image.jpg';
document.head.appendChild(link);
}, []);
return <div>...</div>;
}
5. Server-Side Rendering (SSR)
Use Next.js or similar frameworks for SSR:
// pages/index.jsx (Next.js)
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/data').then(r => r.json());
return {
props: {data}
};
}
export default function Home({data}) {
return (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
</div>
);
}
Optimizing Interaction to Next Paint (INP)
1. Debounce Expensive Operations
import {useState, useCallback} from 'react';
import {debounce} from 'lodash';
function SearchComponent() {
const [results, setResults] = useState([]);
// Debounce search to reduce work
const handleSearch = useCallback(
debounce(async (query) => {
const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
setResults(data);
}, 300),
[]
);
return (
<input
type="text"
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
);
}
2. Use React.memo() for Expensive Components
import {memo} from 'react';
const ExpensiveComponent = memo(function ExpensiveComponent({data}) {
// Complex rendering logic
return (
<div>
{data.items.map(item => (
<ComplexItem key={item.id} {...item} />
))}
</div>
);
});
// Component only re-renders when data changes
3. Optimize Event Handlers
function TodoList({todos}) {
// ❌ Bad: Creates new function on every render
return (
<div>
{todos.map(todo => (
<button onClick={() => handleDelete(todo.id)}>
Delete
</button>
))}
</div>
);
// ✅ Good: Use useCallback
const handleDelete = useCallback((id) => {
deleteTodo(id);
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={handleDelete}
/>
))}
</div>
);
}
4. Use Web Workers for Heavy Calculations
// worker.js
self.addEventListener('message', (e) => {
const result = expensiveCalculation(e.data);
self.postMessage(result);
});
// Component.jsx
import {useEffect, useState} from 'react';
function DataProcessor() {
const [result, setResult] = useState(null);
useEffect(() => {
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage(largeDataset);
worker.onmessage = (e) => {
setResult(e.data);
};
return () => worker.terminate();
}, []);
return <div>{result}</div>;
}
5. Virtualize Long Lists
Use react-window for efficient list rendering:
npm install react-window
import {FixedSizeList} from 'react-window';
function VirtualizedList({items}) {
const Row = ({index, style}) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Optimizing Cumulative Layout Shift (CLS)
1. Reserve Space for Dynamic Content
function UserProfile() {
const [user, setUser] = useState(null);
if (!user) {
// Reserve space with skeleton
return (
<div className="user-profile">
<div className="skeleton avatar" style={{width: 80, height: 80}} />
<div className="skeleton name" style={{width: 200, height: 24}} />
<div className="skeleton bio" style={{width: 300, height: 60}} />
</div>
);
}
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} width={80} height={80} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
2. Set Image Dimensions
function ProductCard({product}) {
return (
<div className="product-card">
<img
src={product.image}
alt={product.name}
width={300}
height={300}
// Prevent shift with aspect ratio
style={{aspectRatio: '1/1'}}
/>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
3. Avoid Inserting Content Above Existing Content
function Feed() {
const [posts, setPosts] = useState([]);
// ❌ Bad: Adds new posts at the top
const loadNewPosts = async () => {
const newPosts = await fetchNewPosts();
setPosts([...newPosts, ...posts]); // Causes layout shift
};
// ✅ Good: Append or use notification
const loadNewPosts = async () => {
const newPosts = await fetchNewPosts();
setHasNewPosts(true); // Show banner
// User clicks to refresh
};
return <div>{/* Feed content */}</div>;
}
4. Use CSS Transforms for Animations
// ❌ Bad: Animating height/width causes layout shifts
const badStyle = {
width: isOpen ? '300px' : '0',
transition: 'width 0.3s'
};
// ✅ Good: Use transform for GPU acceleration
const goodStyle = {
transform: isOpen ? 'scaleX(1)' : 'scaleX(0)',
transition: 'transform 0.3s',
transformOrigin: 'left'
};
React Performance Patterns
1. Component Composition
// ✅ Split components for better optimization
function ProductPage() {
return (
<>
<ProductHeader />
<ProductDetails />
<ProductReviews />
<RelatedProducts />
</>
);
}
// Each can be optimized independently
const ProductReviews = memo(function ProductReviews() {
// Only re-renders when reviews change
});
2. Context Optimization
// Split contexts to avoid unnecessary re-renders
const UserContext = createContext();
const ThemeContext = createContext();
// Instead of one large context
const AppContext = createContext();
3. State Management
// Keep state as local as possible
function TodoApp() {
// ❌ Bad: Global state for everything
const [appState, setAppState] = useState({
todos: [],
filter: 'all',
searchQuery: '',
});
// ✅ Good: Local state where needed
const [todos, setTodos] = useState([]);
return (
<div>
<TodoFilter /> {/* Has its own filter state */}
<TodoSearch /> {/* Has its own search state */}
<TodoList todos={todos} />
</div>
);
}
Real-World Example: Optimized Dashboard
import {lazy, Suspense, memo} from 'react';
import {useWebVitals} from './hooks/useWebVitals';
// Lazy load heavy components
const Chart = lazy(() => import('./components/Chart'));
const DataTable = lazy(() => import('./components/DataTable'));
// Memoize expensive components
const StatCard = memo(function StatCard({title, value, change}) {
return (
<div className="stat-card">
<h3>{title}</h3>
<p className="value">{value}</p>
<span className="change">{change}%</span>
</div>
);
});
function Dashboard() {
// Monitor performance
useWebVitals();
const stats = useStats(); // Custom hook
return (
<div className="dashboard">
{/* Fast-loading stats */}
<div className="stats-grid">
{stats.map(stat => (
<StatCard key={stat.id} {...stat} />
))}
</div>
{/* Lazy load heavy components */}
<Suspense fallback={<LoadingSkeleton />}>
<Chart data={stats.chartData} />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<DataTable data={stats.tableData} />
</Suspense>
</div>
);
}
Testing Performance
Using the Web Vitals Extension
The easiest way to test your React app’s performance is with the Web Vitals Chrome Extension:
- Install the extension
- Open your React app
- Interact with the page
- Check the badge for instant feedback
- Review console logs for detailed metrics
Features you’ll love:
- Real-time monitoring as you develop
- Instant feedback on performance changes
- Console logging with detailed attribution
- HUD overlay for at-a-glance metrics
Performance Profiler
Use React DevTools Profiler:
import {Profiler} from 'react';
function App() {
const onRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.log({id, phase, actualDuration});
};
return (
<Profiler id="App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}
Framework Alternatives
If Core Web Vitals are critical, consider:
Next.js: Server-side rendering, automatic code splitting Remix: Progressive enhancement, optimized data loading Astro: Islands architecture, minimal JavaScript
Conclusion
Optimizing Core Web Vitals in React requires:
- Measure first with web-vitals library
- Code split aggressively
- Memoize expensive components
- Optimize images with dimensions and lazy loading
- Debounce expensive interactions
- Virtualize long lists
- Monitor continuously with the Web Vitals Extension
Start measuring today:
npm install web-vitals
Then monitor live with the Web Vitals Extension.
Resources: