π Lesson 10.1: Performance Optimization
Master the art of building fast, efficient React applications. In the React 19 era the compiler quietly memoizes a lot for you, so the real skill is knowing what to measure, how to read the numbers, and which lever to pull when the numbers are bad. Let's build that instinct.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Use the React DevTools Profiler to see which components render, how long they take, and why they re-rendered
- Measure real-user performance with the Core Web Vitals β LCP, INP, and CLS
- Explain what the React Compiler automates, and when manual
memo/useMemo/useCallbackstill earn their keep - Keep the UI responsive under load with
useTransitionanduseDeferredValue - Split code with
React.lazy+<Suspense>and virtualize long lists - Shrink your bundle, optimize images, and improve Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift
Estimated Time: 75-90 minutes
Project: Profile and optimize a real React application
In This Lesson
π Introduction to Performance Optimization
Welcome to Module 10! You've learned a tremendous amount about building React applications with TypeScript. Now it's time to make them fast. Performance optimization is what separates good applications from great ones.
Performance isn't just about making things fasterβit's about creating better user experiences, improving accessibility, and even helping your application rank better in search engines. A slow app loses users, reduces engagement, and can cost your business real money.
β The Performance Mindset
Think of performance optimization like tuning a car. You wouldn't rip out the engine on day oneβyou'd first find out what's actually slow, measure it, and make one targeted change at a time. The same discipline applies to React: measure, then fix, then measure again. A guess about what's slow is almost always wrong; a profiler is almost always right.
π‘ A word about the React Compiler (React 19)
You may have heard that React 19 ships with an optimizing compiler that memoizes your components and values automaticallyβso you might wonder whether hand-written memo, useMemo, and useCallback are still worth learning. They absolutely are. The compiler removes a lot of the busywork, but understanding what it's doing (and why a render happens at all) is exactly what lets you read a profile, spot the outlier, and know which lever to reach for. We'll call out where the compiler changes the day-to-day advice as we go.
π What We'll Cover in This Lesson
This lesson focuses on practical, real-world performance optimization. We'll learn to:
- Measure - Use React DevTools and browser tools to identify slow parts
- Analyze - Understand what's causing performance problems
- Optimize - Apply targeted fixes that actually make a difference
- Verify - Confirm improvements with measurable metrics
π‘ Why Performance Matters
Before diving into optimization techniques, let's understand why performance is so critical. It's not just about bragging rights or perfectionismβit has real, measurable impacts on your application's success.
π Real-World Impact of Performance
π The Business Case for Performance
Research consistently shows that faster websites perform better:
- Pinterest reduced load times by 40% and saw a 15% increase in sign-ups
- BBC found they lost 10% of users for every additional second of load time
- Amazon calculated that every 100ms delay costs them 1% in sales
- Google uses page speed as a ranking factor for search results
π₯ User Experience Impact
Performance directly affects how users perceive and interact with your application:
| Load Time | User Perception | Likely User Action |
|---|---|---|
| 0-1 second | Instant, fluid | Continues using app confidently |
| 1-3 seconds | Noticeable delay | Stays engaged but notices lag |
| 3-5 seconds | Frustrating wait | Starts getting impatient |
| 5-10 seconds | Unacceptable | Likely to abandon or refresh |
| 10+ seconds | Broken | Leaves and may not return |
β οΈ The Mobile Reality
Most web traffic now comes from mobile devices, often on slower networks. What feels fast on your development laptop with high-speed internet might be painfully slow for real users. Always test on real devices with network throttling!
βΏ Accessibility Considerations
Performance is an accessibility issue. Users with slower devices, older browsers, or limited data plans are disproportionately affected by poor performance. Optimizing your app makes it more accessible to everyone.
// Poor performance example - re-renders on every keystroke
function SlowSearch() {
const [query, setQuery] = useState('');
const results = expensiveSearch(query); // π± Runs on every render!
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults data={results} />
</div>
);
}
// Better approach - we'll learn multiple ways to optimize this!
function FastSearch() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const results = useMemo(
() => expensiveSearch(debouncedQuery),
[debouncedQuery]
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults data={results} />
</div>
);
}
π Measuring Performance
You can't optimize what you can't measure. The first rule of performance optimization is: always measure before and after. Let's explore the tools and metrics we'll use throughout this lesson.
π οΈ Performance Measurement Tools
π Essential Performance Tools
- React DevTools Profiler - Measures React component render performance
- Chrome DevTools Performance Tab - Low-level browser performance profiling
- Lighthouse - Automated performance audits and recommendations
- web-vitals - The tiny library Google publishes for measuring real-user Core Web Vitals
- Bundle Analyzers - Visualize what's in your JavaScript bundles
π Key Performance Metrics
Modern web performance centers around Google's Core Web Vitals, three metrics that measure real user experience:
β οΈ Heads up: FID is retired
If you learned React a few years ago, you'll remember First Input Delay (FID) as the third Core Web Vital. In March 2024 Google replaced FID with Interaction to Next Paint (INP). INP is a strictly better metric: FID only measured the delay before the first interaction, while INP measures the responsiveness of every interaction across the whole visit. Tools, docs, and the web-vitals library have all moved to INP, so that's what we teach here.
Largest Contentful Paint"] A --> C["INP
Interaction to Next Paint"] A --> D["CLS
Cumulative Layout Shift"] B --> B1["Loading Performance
How fast main content appears
Goal: under 2.5 seconds"] C --> C1["Responsiveness
How quickly every interaction paints
Goal: under 200 milliseconds"] D --> D1["Visual Stability
How much the page shifts
Goal: under 0.1"] style A fill:#667eea,color:#fff style B fill:#c8e6c9 style C fill:#c8e6c9 style D fill:#c8e6c9 style B1 fill:#e8f5e9 style C1 fill:#e8f5e9 style D1 fill:#e8f5e9
π― Understanding Each Metric
π‘ Largest Contentful Paint (LCP)
What it measures: How long it takes for the largest visible element (hero image, heading, text block) to appear on screen.
Why it matters: Users perceive the page as loaded when they see the main content.
Good score: Less than 2.5 seconds
Common issues: Large images, render-blocking JavaScript, slow server response times
π‘ Interaction to Next Paint (INP)
What it measures: Across the whole visit, how long the slowest interactions take from the user's action (click, tap, key press) to the next frame the browser paints in response.
Why it matters: Users get frustrated when a button click, a menu open, or a keystroke feels laggy β INP captures that "did anything happen?" feeling that FID missed.
Good score: Less than 200 milliseconds (needs work: 200β500ms; poor: over 500ms)
Common issues: Long JavaScript tasks blocking the main thread, heavy re-renders triggered by an interaction
π‘ Cumulative Layout Shift (CLS)
What it measures: How much visible content shifts around as the page loads.
Why it matters: Unexpected layout shifts cause users to click the wrong things and create a janky experience.
Good score: Less than 0.1
Common issues: Images without dimensions, web fonts causing text to shift, dynamic content insertion
π Using Lighthouse for Quick Audits
Lighthouse is built into Chrome DevTools and provides instant performance insights. Let's learn how to use it:
// To run a Lighthouse audit:
// 1. Open Chrome DevTools (F12 or Cmd+Option+I)
// 2. Click the "Lighthouse" tab
// 3. Select "Performance" category
// 4. Click "Analyze page load"
// Lighthouse gives you a score (0-100) and specific recommendations
// like:
// - "Serve images in next-gen formats (WebP)"
// - "Reduce unused JavaScript"
// - "Properly size images"
// - "Eliminate render-blocking resources"
β Pro Tip: Run Multiple Tests
Performance can vary based on network conditions, CPU load, and other factors. Always run Lighthouse at least 3 times and look at the average scores. Test on both desktop and mobile, and use "Incognito mode" to avoid browser extensions affecting results.
π Establishing a Performance Baseline
Before optimizing anything, establish baseline metrics for your application:
// The easiest, most accurate way to read your baseline is the
// official web-vitals library β it handles all the tricky observer
// edge cases (including INP) for you.
// npm install web-vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';
// Log each metric to the console as it becomes available.
function logMetric(metric: Metric): void {
// metric.name is 'CLS' | 'INP' | 'LCP' | 'FCP' | 'TTFB'
// metric.rating is 'good' | 'needs-improvement' | 'poor'
console.log(`${metric.name}: ${metric.value.toFixed(1)} (${metric.rating})`);
}
// Wire them up once, as early as possible.
export function reportBaseline(): void {
onLCP(logMetric); // Largest Contentful Paint
onINP(logMetric); // Interaction to Next Paint (replaced FID)
onCLS(logMetric); // Cumulative Layout Shift
onFCP(logMetric); // First Contentful Paint
onTTFB(logMetric); // Time to First Byte
}
// Use in your app
function App() {
useEffect(() => {
reportBaseline();
}, []);
return <YourApp />;
}
Rolling your own PerformanceObserver is a great way to learn how these metrics are captured, but INP in particular is fiddly to measure by hand. Reach for the library in real projects β it's about 2 KB and Google keeps it in lock-step with the official definitions.
β οΈ Development vs Production
React's development build is significantly slower than production due to warnings, stack traces, and other debugging features. Always test performance with a production build!
npm run build
npm run preview # or serve the build folder
π¬ React DevTools Profiler
The React DevTools Profiler is your best friend for identifying React-specific performance issues. It shows exactly which components are rendering, how long they take, and why they re-rendered.
π Installing React DevTools
React DevTools is available as a browser extension:
- Chrome: Chrome Web Store
- Firefox: Firefox Add-ons
- Edge: Use the Chrome extension
Once installed, you'll see two new tabs in your DevTools: "Components" and "Profiler".
π Using the Profiler
β Step-by-Step: Profiling Your App
- Open React DevTools and click the "Profiler" tab
- Click the blue record button (βΊοΈ)
- Interact with your app (the actions you want to measure)
- Click the record button again to stop
- Analyze the flame graph and commit data
π₯ Understanding the Flame Graph
The Profiler displays a flame graph showing your component tree and render times:
The flame graph is colour-coded by how long each component took to render:
- π© Green = Fast β nothing to see here (good!)
- π§ Yellow / Orange = Slow β worth a closer look
- π₯ Red = Very slow β this is where you start optimizing
The width of each bar is its render time, so the widest, reddest bars are your biggest wins. In the tree above, ArticleList (and the App and MainContent it sits inside) dominate the frame β that's your prime suspect.
π― What the Profiler Shows
For each component render, you can see:
| Metric | What It Means | What to Look For |
|---|---|---|
| Render duration | How long the component took to render | Components taking >16ms (one frame at 60fps) |
| Why did this render? | What caused the component to re-render | Unnecessary renders (props didn't actually change) |
| Render count | How many times it rendered during recording | Components rendering too frequently |
| Committed at | When the render was committed to DOM | Patterns in timing (e.g., all renders at once) |
π‘ Practical Example: Finding Slow Components
Let's profile a real application and identify issues:
// Example: A product list that's rendering slowly
interface Product {
id: number;
name: string;
price: number;
description: string;
rating: number;
}
// π± Slow component - re-renders unnecessarily
function ProductCard({ product }: { product: Product }) {
console.log('ProductCard rendering:', product.name);
// Expensive calculation on every render
const formattedPrice = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(product.price);
// Complex rendering logic
const stars = Array.from({ length: 5 }, (_, i) => {
return i < Math.floor(product.rating) ? 'β
' : 'β';
}).join('');
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>{formattedPrice}</p>
<p>{stars} ({product.rating})</p>
<p>{product.description}</p>
</div>
);
}
function ProductList({ products }: { products: Product[] }) {
const [sortBy, setSortBy] = useState<'name' | 'price'>('name');
// Sorting happens on every render!
const sortedProducts = [...products].sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
}
return a.price - b.price;
});
return (
<div>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
{sortedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
β οΈ What the Profiler Would Show
When you profile this component:
- ProductList renders on every sort change β (expected)
- Every ProductCard re-renders even though product data didn't change π± (wasteful!)
- The sorting calculation runs on every render π± (unnecessary!)
- Number formatting happens repeatedly for the same prices π± (expensive!)
We'll learn how to fix these issues in the next section!
π Analyzing "Why Did This Render?"
One of the Profiler's most useful features is showing why a component rendered:
// Click on any component in the Profiler to see:
// β
Good reasons to render:
// - "State changed" - Component's own state updated
// - "Props changed" - Parent passed different props
// - "Parent rendered" - But props actually changed
// π± Bad reasons (opportunities to optimize):
// - "Parent rendered" - But props are the same
// - "Context changed" - But component doesn't use the changed value
// - "Props changed" - But the "change" is a new object with same values
β Pro Tip: The "Ranked" View
Switch to the "Ranked" chart view in the Profiler to see components sorted by render time. This instantly shows you where to focus your optimization effortsβstart with the slowest components at the top!
π Identifying Performance Bottlenecks
Now that we know how to measure performance, let's learn to identify the most common bottlenecks in React applications. Understanding what's slow is the first step to making it fast.
π― Common Performance Bottlenecks
React applications typically suffer from these performance issues:
π Bottleneck #1: Excessive Re-renders
The #1 cause of slow React apps is components rendering more often than necessary. Every render has a cost!
// π± Anti-pattern: Creating new objects in render
function ParentComponent() {
const [count, setCount] = useState(0);
// This creates a NEW object on every render!
const config = { theme: 'dark', locale: 'en' };
// This creates a NEW function on every render!
const handleClick = () => console.log('clicked');
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
{/* These components re-render even though nothing changed! */}
<ChildComponent config={config} onClick={handleClick} />
</div>
);
}
// β
Fixed version
function ParentComponent() {
const [count, setCount] = useState(0);
// Move constant objects outside component
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
// Memoize functions
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
{/* Now ChildComponent only renders when props actually change */}
<MemoizedChildComponent config={config} onClick={handleClick} />
</div>
);
}
const MemoizedChildComponent = React.memo(ChildComponent);
β The React Compiler does most of this for you
The hand-written useMemo around config, the useCallback around handleClick, the React.memo wrapper β in a project with the React Compiler enabled, you often write none of them. The compiler analyses your components at build time and inserts equivalent memoization automatically, so the "new object / new function every render" trap mostly disappears.
So why learn the manual versions? Because the compiler needs your components to follow the Rules of React to kick in, it doesn't cover every case (third-party code, opt-outs, gradual adoption), and the mental model β "a new reference makes a memoized child re-render" β is exactly what you need to read a profile. Treat manual memoization as the tool you reach for when the profiler tells you to, not a reflex on every prop.
π‘ How to Spot This Issue
In the Profiler, look for:
- Components showing "Parent rendered" as the render reason
- Props that show as "changed" but have the same values
- Many components rendering simultaneously when only one should
π Bottleneck #2: Expensive Computations
Calculations that run on every render can severely impact performance:
// π± Expensive computation runs every render
function DataDashboard({ data }: { data: DataPoint[] }) {
// This sorts and filters thousands of items on EVERY RENDER!
const processedData = data
.filter(point => point.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 100);
const average = processedData.reduce((sum, point) => sum + point.value, 0) / processedData.length;
return (
<div>
<h2>Average: {average.toFixed(2)}</h2>
<DataTable data={processedData} />
</div>
);
}
// β
Memoized version - only recalculates when data changes
function DataDashboard({ data }: { data: DataPoint[] }) {
const processedData = useMemo(() => {
console.log('Computing processed data...');
return data
.filter(point => point.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 100);
}, [data]); // Only recompute when data changes
const average = useMemo(() => {
console.log('Computing average...');
return processedData.reduce((sum, point) => sum + point.value, 0) / processedData.length;
}, [processedData]);
return (
<div>
<h2>Average: {average.toFixed(2)}</h2>
<DataTable data={processedData} />
</div>
);
}
β Rule of Thumb: When to useMemo
Use useMemo when:
- The computation is expensive (loops through large arrays, complex math)
- The result is used as a prop to child components
- The computation happens frequently but dependencies change rarely
Don't use useMemo for simple calculationsβit adds overhead!
π Bottleneck #3: Large Lists
Rendering thousands of items is expensive, even if they're simple:
// π± Renders 10,000 items - page becomes unresponsive
function HugeList({ items }: { items: Item[] }) {
return (
<div className="list">
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</div>
);
}
// β
Solution 1: Virtualization (only render visible items)
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<ListItem item={items[index]} />
</div>
)}
</FixedSizeList>
);
}
// β
Solution 2: Pagination
function PaginatedList({ items }: { items: Item[] }) {
const [page, setPage] = useState(0);
const itemsPerPage = 50;
const paginatedItems = useMemo(() => {
const start = page * itemsPerPage;
return items.slice(start, start + itemsPerPage);
}, [items, page]);
return (
<div>
{paginatedItems.map(item => (
<ListItem key={item.id} item={item} />
))}
<Pagination
page={page}
totalPages={Math.ceil(items.length / itemsPerPage)}
onPageChange={setPage}
/>
</div>
);
}
π‘ Virtualization Libraries
For large lists, use a virtualization library that renders only the visible items:
- @tanstack/react-virtual - The modern default. Headless (you own the markup), tiny, and works for lists, grids, and tables with fixed or variable sizes.
- react-window - The classic lightweight choice. Ready-made
<FixedSizeList>/<VariableSizeList>components; great when you want batteries included. (Note: react-virtualized is its older, heavier predecessor β prefer react-window or TanStack Virtual for new code.)
We'll show both APIs later in this lesson so you can recognize either in the wild.
π Performance Checklist
Use this checklist when investigating performance issues:
| β Check | What to Look For | Tool to Use |
|---|---|---|
| Rendering frequency | Components rendering too often | React DevTools Profiler |
| Render duration | Slow component renders (>16ms) | React DevTools Profiler |
| JavaScript execution | Long tasks blocking the main thread | Chrome DevTools Performance |
| Bundle size | Large JavaScript files | Bundle Analyzer |
| Network requests | Slow or blocking resources | Chrome Network tab |
| Images | Unoptimized or oversized images | Lighthouse |
| Memory leaks | Memory usage growing over time | Chrome Memory Profiler |
β‘ Render Optimization Strategies
Now let's dive into specific techniques for optimizing React component renders. These are the practical tools you'll use every day to keep your applications fast.
π€ First: does the React Compiler already handle this?
The next few strategies β React.memo, useMemo, useCallback β are manual memoization. In a React 19 project with the React Compiler turned on, the compiler applies equivalent memoization automatically, so a lot of this code becomes optional. The order of operations in 2026 is:
- Write clear components that follow the Rules of React.
- Enable the React Compiler and let it memoize for you.
- Profile. If something is still slow, reach for manual memoization at that spot.
Everything below is still essential knowledge: it's the mental model behind what the compiler does, it's what you use when the compiler can't (opt-outs, libraries, gradual migration), and it's how you interpret a profile. So learn the tools β just don't sprinkle them everywhere out of habit.
π‘ Enabling the React Compiler
The compiler ships as a Babel plugin and a matching lint rule. In a Vite project it's roughly:
npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
],
});
The companion eslint-plugin-react-hooks (v5+) flags components that break the Rules of React so the compiler can safely optimize them.
π― Strategy #1: React.memo
React.memo is a higher-order component that prevents unnecessary re-renders by memoizing the component output:
Let's make the re-render cascade concrete. Imagine an <App> that renders three children β a <Counter> that receives the changing count, and a <Header> and <Footer> whose props never change. Update the parent's state a few times, then flip React.memo on for Header and Footer and watch the wasted renders disappear.
<App count> β <Counter count/> <Header title/> <Footer year/>
π Update the parent a few times, then toggle React.memo and compare.
π‘ Reading the demo
<Counter> re-renders every time because its count prop genuinely changes β that's correct work. <Header> and <Footer> get identical props every time, so with React.memo they're skipped and the "wasted" counter stops climbing. In a React Compiler project this skip usually happens for you; the demo shows the mechanism the compiler is automating.
// Without React.memo
function ExpensiveComponent({ data }: { data: ComplexData }) {
console.log('ExpensiveComponent rendering');
// Imagine this has expensive rendering logic
return (
<div>
<h3>{data.title}</h3>
<ComplexVisualization data={data} />
</div>
);
}
// With React.memo - only re-renders if props change
const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);
// Usage
function ParentComponent() {
const [count, setCount] = useState(0);
const complexData = { title: 'Chart', values: [1, 2, 3] };
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
{/* This won't re-render when count changes */}
<MemoizedExpensiveComponent data={complexData} />
</div>
);
}
β οΈ React.memo Gotcha: Reference Equality
React.memo uses shallow comparison. If you pass objects or functions as props, they need to be memoized too, or React.memo won't help:
// π± Still re-renders because data is a new object each time
function Parent() {
const [count, setCount] = useState(0);
return (
<MemoizedChild data={{ value: 123 }} /> // New object every render!
);
}
// β
Fixed with useMemo
function Parent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ value: 123 }), []);
return <MemoizedChild data={data} />;
}
π― Strategy #2: useMemo for Expensive Calculations
We've seen useMemo briefly. Let's explore it in depth:
// Example: Filtering and sorting a large dataset
interface Product {
id: number;
name: string;
price: number;
category: string;
rating: number;
}
function ProductFilter({ products }: { products: Product[] }) {
const [searchTerm, setSearchTerm] = useState('');
const [category, setCategory] = useState('all');
const [sortBy, setSortBy] = useState<'name' | 'price' | 'rating'>('name');
// π± Without useMemo - runs on every render (even when typing!)
const filteredAndSorted = products
.filter(p =>
(category === 'all' || p.category === category) &&
p.name.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price') return a.price - b.price;
return b.rating - a.rating;
});
// β
With useMemo - only recalculates when dependencies change
const filteredAndSortedMemo = useMemo(() => {
console.log('Filtering and sorting...');
return products
.filter(p =>
(category === 'all' || p.category === category) &&
p.name.toLowerCase().includes(searchTerm.toLowerCase())
)
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price') return a.price - b.price;
return b.rating - a.rating;
});
}, [products, category, searchTerm, sortBy]);
return (
<div>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
<ProductList products={filteredAndSortedMemo} />
</div>
);
}
β useMemo Best Practices
- Do use for expensive computations (array operations on large data, complex math)
- Do use when the result is passed to child components
- Don't use for simple calculations (it adds overhead)
- Don't use as a semantic guarantee (React may still recalculate)
- Always include all dependencies in the dependency array
π― Strategy #3: useCallback for Function Memoization
useCallback is like useMemo for functions:
// Example: Passing callbacks to memoized children
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoItem = React.memo(({ todo, onToggle, onDelete }: TodoItemProps) => {
console.log('TodoItem rendering:', todo.text);
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// π± Without useCallback - new functions every render
const handleToggle = (id: number) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const handleDelete = (id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// β
With useCallback - functions stay the same
const handleToggleMemo = useCallback((id: number) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []); // No dependencies needed - uses functional update
const handleDeleteMemo = useCallback((id: number) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggleMemo}
onDelete={handleDeleteMemo}
/>
))}
</div>
);
}
Key insight: useCallback is only useful when passing functions to memoized child components. If the child isn't memoized with React.memo, useCallback provides no benefit.
π― Strategy #4: Component Lazy Loading
Not all components need to be loaded immediately. Use React.lazy and Suspense to split code and load components on demand:
// Without lazy loading - entire app loads at once
import { HeavyChart } from './components/HeavyChart';
import { ComplexEditor } from './components/ComplexEditor';
import { VideoPlayer } from './components/VideoPlayer';
function Dashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tab value="overview">Overview</Tab>
<Tab value="charts">Charts</Tab>
<Tab value="editor">Editor</Tab>
</Tabs>
{activeTab === 'overview' && <Overview />}
{activeTab === 'charts' && <HeavyChart />}
{activeTab === 'editor' && <ComplexEditor />}
</div>
);
}
// β
With lazy loading - components load only when needed
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const ComplexEditor = lazy(() => import('./components/ComplexEditor'));
const VideoPlayer = lazy(() => import('./components/VideoPlayer'));
function Dashboard() {
const [activeTab, setActiveTab] = useState('overview');
return (
<div>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tab value="overview">Overview</Tab>
<Tab value="charts">Charts</Tab>
<Tab value="editor">Editor</Tab>
</Tabs>
<Suspense fallback={<LoadingSpinner />}>
{activeTab === 'overview' && <Overview />}
{activeTab === 'charts' && <HeavyChart />}
{activeTab === 'editor' && <ComplexEditor />}
</Suspense>
</div>
);
}
π‘ When to Use Lazy Loading
- Route-based splits - Different routes can load their own code
- Modal dialogs - Only load when the modal opens
- Heavy components - Charts, editors, video players
- Below-the-fold content - Content users might not scroll to
- Admin features - Code that only some users need
π― Strategy #5: Windowing Large Lists
For long lists, render only what's visible on screen using virtualization:
// Installing react-window
// npm install react-window @types/react-window
import { FixedSizeList } from 'react-window';
interface Message {
id: number;
author: string;
text: string;
timestamp: Date;
}
// π± Without windowing - renders all 10,000 messages
function ChatHistory({ messages }: { messages: Message[] }) {
return (
<div className="chat-history">
{messages.map(message => (
<MessageItem key={message.id} message={message} />
))}
</div>
);
}
// β
With windowing - only renders visible messages
function VirtualizedChatHistory({ messages }: { messages: Message[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const message = messages[index];
return (
<div style={style}>
<MessageItem message={message} />
</div>
);
};
return (
<FixedSizeList
height={600} // Visible height in pixels
itemCount={messages.length}
itemSize={80} // Height of each message
width="100%"
>
{Row}
</FixedSizeList>
);
}
// For variable height items, use VariableSizeList
import { VariableSizeList } from 'react-window';
function VariableHeightList({ items }: { items: Item[] }) {
const listRef = useRef<VariableSizeList>(null);
const rowHeights = useRef<{ [key: number]: number }>({});
const getItemSize = (index: number) => {
return rowHeights.current[index] || 50; // Default height
};
const setRowHeight = (index: number, size: number) => {
listRef.current?.resetAfterIndex(0);
rowHeights.current[index] = size;
};
const Row = ({ index, style }: any) => {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rowRef.current) {
setRowHeight(index, rowRef.current.clientHeight);
}
}, [index]);
return (
<div style={style}>
<div ref={rowRef}>
<VariableHeightItem item={items[index]} />
</div>
</div>
);
};
return (
<VariableSizeList
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}
β Windowing Performance Impact
Windowing can provide dramatic improvements:
- 1,000 items: Renders ~20 instead of 1,000 (50x faster)
- 10,000 items: Renders ~20 instead of 10,000 (500x faster)
- 100,000 items: Renders ~20 instead of 100,000 (5000x faster)
The performance is constant regardless of list size!
The react-window code above is still perfectly good, but for new projects the community default is now TanStack Virtual. It's headless β it hands you positions and sizes, and you render the markup β which means it works for lists, grids, tables, and variable-height rows with one small API:
// npm install @tanstack/react-virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualMessages({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // rough row height; measured rows can vary
overscan: 5, // render a few extra rows above/below
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
{/* One tall spacer element sets the total scrollable height */}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: row.size,
transform: `translateY(${row.start}px)`,
}}
>
<MessageItem message={messages[row.index]} />
</div>
))}
</div>
</div>
);
}
π― Strategy #6: Debouncing and Throttling
Limit how often expensive operations run in response to user input:
// Debounce - wait until user stops typing
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Using debounce for search
function SearchBar() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
// This only runs when user stops typing for 300ms
useEffect(() => {
if (debouncedSearchTerm) {
fetchSearchResults(debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
}
// Throttle - limit how often a function can run
function useThrottle<T>(value: T, limit: number): T {
const [throttledValue, setThrottledValue] = useState<T>(value);
const lastRan = useRef(Date.now());
useEffect(() => {
const handler = setTimeout(() => {
if (Date.now() - lastRan.current >= limit) {
setThrottledValue(value);
lastRan.current = Date.now();
}
}, limit - (Date.now() - lastRan.current));
return () => {
clearTimeout(handler);
};
}, [value, limit]);
return throttledValue;
}
// Using throttle for scroll events
function ScrollTracker() {
const [scrollPosition, setScrollPosition] = useState(0);
const throttledPosition = useThrottle(scrollPosition, 100);
useEffect(() => {
const handleScroll = () => {
setScrollPosition(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// This only updates every 100ms instead of every frame
useEffect(() => {
updateScrollIndicator(throttledPosition);
}, [throttledPosition]);
return <div>Scroll position: {throttledPosition}</div>;
}
Wait 300ms Debounce->>API: Search for "hello" API->>User: Show results
π‘ Debounce vs Throttle
| Technique | When It Runs | Use Cases |
|---|---|---|
| Debounce | After user stops triggering events | Search input, resize events, form validation |
| Throttle | At most once per time interval | Scroll events, mouse movements, API polling |
π― Strategy #7: Concurrent Rendering with useTransition & useDeferredValue
Debouncing hides an expensive render by delaying it. React has a better, built-in answer: keep the urgent update (the keystroke) instant, and let the expensive update (re-filtering 10,000 rows) happen in the background without blocking the input. That's what the concurrent hooks useTransition and useDeferredValue do β and they're a big part of keeping INP low.
useTransition gives you a startTransition function to wrap non-urgent state updates, plus an isPending flag so you can show a subtle "updatingβ¦" hint:
import { useState, useTransition, useMemo } from 'react';
function ProductSearch({ products }: { products: Product[] }) {
const [text, setText] = useState(''); // urgent: what's in the box
const [query, setQuery] = useState(''); // non-urgent: what we filter by
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setText(e.target.value); // update the input immediately
startTransition(() => {
setQuery(e.target.value); // filtering can lag behind β that's fine
});
}
const results = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
[products, query]
);
return (
<div>
<input value={text} onChange={handleChange} placeholder="Search..." />
{isPending && <span> updatingβ¦</span>}
<SearchResults data={results} />
</div>
);
}
useDeferredValue is the even simpler cousin when you don't own the state update β you just defer a value. React renders once with the old value (keeping typing snappy), then re-renders with the new value when it has spare time:
import { useState, useDeferredValue, useMemo } from 'react';
function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // lags behind `query`
const results = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[products, deferredQuery]
);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<SearchResults data={results} />
</div>
</div>
);
}
β Transitions vs debouncing β which and when?
- Debounce when the goal is to fire fewer side effects β e.g. don't hit the network on every keystroke. Debounce the fetch.
- useTransition / useDeferredValue when the goal is to keep the UI responsive during an expensive render β the work still happens, it just yields to urgent updates so the input never stutters.
- They compose: debounce the API call and defer the render of the results.
π¦ Code Splitting and Bundle Optimization
One of the biggest impacts on initial load time is JavaScript bundle size. Let's learn how to split your code into smaller chunks that load only when needed.
π― Understanding Bundle Size
Modern React applications can easily balloon to megabytes of JavaScript. Every kilobyte must be downloaded, parsed, and executed before your app becomes interactive.
β οΈ The Real Cost of JavaScript
JavaScript is the most expensive resource on the web:
- Download: Time spent transferring over the network
- Parse: Browser must parse the code (can take 50-100ms per MB)
- Compile: JIT compilation to machine code
- Execute: Running your code
A 1MB image only needs to be downloaded and decoded. A 1MB JavaScript file goes through all four steps and blocks interactivity!
π Analyzing Your Bundle
First, let's see what's in your bundle:
# Install bundle analyzer
npm install --save-dev @rollup/plugin-visualizer
# For Vite projects, add to vite.config.ts:
import { visualizer } from '@rollup/plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
})
]
});
# Build and analyze
npm run build
This generates a visual treemap showing what's taking up space in your bundle:
π― Route-Based Code Splitting
The most effective code splitting strategy is to split by route:
// Before: All routes load at once
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</BrowserRouter>
);
}
// β
After: Each route loads on demand
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
β Code Splitting Impact
Route-based splitting typically:
- Reduces initial bundle by 40-60%
- Improves Time to Interactive by 2-4 seconds
- Better caching (unchanged routes stay cached)
π― Component-Based Code Splitting
Split heavy components that aren't immediately needed:
// Split heavy third-party components
import { lazy, Suspense } from 'react';
const ChartComponent = lazy(() => import('react-chartjs-2'));
const MarkdownEditor = lazy(() => import('react-markdown-editor'));
const CodeEditor = lazy(() => import('@monaco-editor/react'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>
Show Analytics Chart
</button>
{showChart && (
<Suspense fallback={<p>Loading chart...</p>}>
<ChartComponent data={analyticsData} />
</Suspense>
)}
<button onClick={() => setShowEditor(true)}>
Open Editor
</button>
{showEditor && (
<Suspense fallback={<p>Loading editor...</p>}>
<CodeEditor defaultValue="// Start coding..." />
</Suspense>
)}
</div>
);
}
π― Optimizing Dependencies
Many bundle size issues come from dependencies. Here's how to optimize them:
// β Bad: Importing entire lodash (70KB)
import _ from 'lodash';
const result = _.debounce(fn, 300);
// β
Good: Import only what you need (5KB)
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// β Bad: Importing entire date-fns (200KB+)
import { format } from 'date-fns';
// β
Good: Use the smaller date-fns/format (15KB)
import format from 'date-fns/format';
// β Bad: Importing massive icon library
import { FaUser, FaHome, FaCog } from 'react-icons/fa';
// β
Good: Import specific icons
import FaUser from 'react-icons/fa/FaUser';
import FaHome from 'react-icons/fa/FaHome';
import FaCog from 'react-icons/fa/FaCog';
// Or better: Use smaller icon library
import { User, Home, Settings } from 'lucide-react'; // Much smaller!
π‘ Alternative Lightweight Libraries
| Instead of... | Try... | Size Savings |
|---|---|---|
| moment.js (289KB) | date-fns (15-20KB) or dayjs (7KB) | 93-97% smaller |
| lodash (70KB) | Individual imports or native JS | 90%+ smaller |
| axios (13KB) | fetch API (built-in) | 100% smaller |
| react-icons (1MB+) | lucide-react (50KB) | 95% smaller |
π― Tree Shaking
Modern bundlers automatically remove unused code, but you need to write code that's "tree-shakeable":
// β
Tree-shakeable: ES6 modules with named exports
export const add = (a: number, b: number) => a + b;
export const subtract = (a: number, b: number) => a - b;
export const multiply = (a: number, b: number) => a * b;
// Usage - only 'add' is bundled
import { add } from './math';
// β Not tree-shakeable: Default export of object
export default {
add: (a: number, b: number) => a + b,
subtract: (a: number, b: number) => a - b,
multiply: (a: number, b: number) => a * b,
};
// Usage - entire object is bundled even if you only use 'add'
import math from './math';
math.add(1, 2);
π― Prefetching and Preloading
Load code before it's needed to make transitions feel instant:
// Prefetch on hover - loads when user hovers over link
import { lazy, Suspense } from 'react';
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
// Preload function
const preloadAdminPanel = () => {
import('./pages/AdminPanel');
};
function Navigation() {
return (
<nav>
<Link to="/">Home</Link>
<Link
to="/admin"
onMouseEnter={preloadAdminPanel} // Prefetch on hover
onFocus={preloadAdminPanel} // Prefetch on focus (keyboard)
>
Admin Panel
</Link>
</nav>
);
}
// Prefetch on idle - loads when browser is idle
function App() {
useEffect(() => {
// Prefetch routes user is likely to visit
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('./pages/Dashboard');
import('./pages/Settings');
});
} else {
// Fallback for browsers without requestIdleCallback
setTimeout(() => {
import('./pages/Dashboard');
import('./pages/Settings');
}, 1000);
}
}, []);
return <YourApp />;
}
β Prefetching Strategy
- Critical: Load immediately (homepage, shell)
- High priority: Prefetch on hover/focus (likely next pages)
- Medium priority: Load on idle (common destinations)
- Low priority: Load on demand (rare admin features)
πΌοΈ Image Optimization
Images are often the largest assets on a webpage. Optimizing them can dramatically improve load times and Core Web Vitals scores.
π The Image Problem
β οΈ Common Image Mistakes
- Serving 4000Γ3000 images when displaying at 400Γ300
- Using PNG for photos instead of JPG
- Not using modern formats like WebP or AVIF
- No lazy loading for below-the-fold images
- Images without dimensions causing layout shifts
π― Modern Image Formats
| Format | Best For | File Size | Browser Support |
|---|---|---|---|
| WebP | Most images | 25-35% smaller than JPEG/PNG | 97% (IE no support) |
| AVIF | High quality photos | 50% smaller than JPEG | 90% (older browsers no support) |
| JPEG | Photos (fallback) | Baseline | 100% |
| PNG | Graphics with transparency | Large for photos | 100% |
| SVG | Icons, logos, graphics | Tiny, scalable | 100% |
π― Responsive Images
Serve different image sizes for different screen sizes:
// β
Using srcset for responsive images
function ProductImage({ product }: { product: Product }) {
return (
<img
src={product.image.medium}
srcSet={`
${product.image.small} 480w,
${product.image.medium} 800w,
${product.image.large} 1200w,
${product.image.xlarge} 1600w
`}
sizes="(max-width: 600px) 480px,
(max-width: 1000px) 800px,
(max-width: 1400px) 1200px,
1600px"
alt={product.name}
loading="lazy"
width={800}
height={600}
/>
);
}
// β
Using <picture> for art direction and format fallbacks
function HeroImage() {
return (
<picture>
{/* Modern format for supported browsers */}
<source
type="image/avif"
srcSet="/hero-small.avif 480w, /hero-large.avif 1200w"
sizes="100vw"
/>
<source
type="image/webp"
srcSet="/hero-small.webp 480w, /hero-large.webp 1200w"
sizes="100vw"
/>
{/* Fallback for older browsers */}
<img
src="/hero-large.jpg"
srcSet="/hero-small.jpg 480w, /hero-large.jpg 1200w"
sizes="100vw"
alt="Hero image"
loading="eager"
width={1200}
height={600}
/>
</picture>
);
}
β Always Include Dimensions
Always specify width and height attributes to prevent Cumulative Layout Shift (CLS):
// β Bad: No dimensions = layout shift when image loads
<img src="/photo.jpg" alt="Photo" />
// β
Good: Browser reserves space
<img src="/photo.jpg" alt="Photo" width={800} height={600} />
// β
Also good: Use aspect-ratio CSS
<img
src="/photo.jpg"
alt="Photo"
style={{ aspectRatio: '16/9', width: '100%' }}
/>
π― Lazy Loading Images
Don't load images until they're about to enter the viewport:
// β
Native lazy loading (simple and effective!)
function Gallery({ images }: { images: Image[] }) {
return (
<div className="gallery">
{images.map((image, index) => (
<img
key={image.id}
src={image.url}
alt={image.alt}
loading={index < 3 ? 'eager' : 'lazy'} // Load first 3 immediately
width={400}
height={300}
/>
))}
</div>
);
}
// β
Advanced: Intersection Observer for more control
function LazyImage({ src, alt, ...props }: ImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (!imgRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{
rootMargin: '200px', // Start loading 200px before entering viewport
}
);
observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isInView ? src : undefined}
alt={alt}
onLoad={() => setIsLoaded(true)}
style={{
opacity: isLoaded ? 1 : 0,
transition: 'opacity 0.3s',
}}
{...props}
/>
);
}
π― Image CDN and Optimization Services
Consider using image CDNs that automatically optimize images:
// Using Cloudinary for automatic optimization
function OptimizedImage({ src, alt, width, height }: ImageProps) {
const cloudinaryUrl = `https://res.cloudinary.com/your-cloud/image/upload/f_auto,q_auto,w_${width}/${src}`;
return (
<img
src={cloudinaryUrl}
alt={alt}
width={width}
height={height}
loading="lazy"
/>
);
}
// f_auto: Automatically choose best format (WebP, AVIF, etc.)
// q_auto: Automatically choose optimal quality
// w_${width}: Resize to exact width needed
π‘ Popular Image CDN Services
- Cloudinary - Full-featured, automatic optimization
- Imgix - Real-time image processing
- Cloudflare Images - Simple, integrated with CF
- ImageKit - Good free tier
π Image Optimization Checklist
| β Optimization | Impact | Difficulty |
|---|---|---|
| Use WebP/AVIF formats | 25-50% smaller files | Easy |
| Add width/height attributes | Prevents CLS | Easy |
| Lazy load images | Faster initial load | Easy |
| Use responsive images (srcset) | Save bandwidth | Medium |
| Compress images | 20-40% smaller | Easy |
| Use CDN for images | Faster delivery | Medium |
π Improving Core Web Vitals
Now let's put everything together and focus specifically on improving the three Core Web Vitals metrics that Google uses to measure user experience.
π― Improving Largest Contentful Paint (LCP)
LCP measures how quickly the main content loads. Target: < 2.5 seconds
π‘ What Counts as LCP?
The largest element visible in the viewport, typically:
- Hero images or banners
- Large text blocks
- Video thumbnails
- Background images loaded via CSS
π§ LCP Optimization Strategies
// 1. Preload critical images
function App() {
return (
<>
<Helmet>
<link
rel="preload"
as="image"
href="/hero-image.webp"
imagesrcset="/hero-small.webp 480w, /hero-large.webp 1200w"
imagesizes="100vw"
/>
</Helmet>
<HeroSection />
</>
);
}
// 2. Optimize hero images
function HeroSection() {
return (
<section className="hero">
<img
src="/hero-large.webp"
srcSet="/hero-small.webp 480w, /hero-large.webp 1200w"
sizes="100vw"
alt="Hero"
width={1200}
height={600}
loading="eager" // Don't lazy load the hero!
fetchPriority="high" // Tell browser this is important
/>
</section>
);
}
// 3. Server-side rendering for faster initial content
// Using frameworks like Next.js, Remix, or Gatsby
export async function getServerSideProps() {
const data = await fetchInitialData();
return {
props: { data }
};
}
// 4. Inline critical CSS
// In your build process, inline CSS for above-the-fold content
<style>
{/* Critical CSS for hero section */}
.hero {
min-height: 400px;
background: #f0f0f0;
}
</style>
β LCP Quick Wins
- Use a CDN for faster asset delivery
- Optimize server response times (TTFB < 600ms)
- Preload the LCP image
- Use modern image formats (WebP, AVIF)
- Remove render-blocking JavaScript and CSS
- Consider using SSR for critical content
π― Improving Interaction to Next Paint (INP)
INP measures responsiveness across every interaction. Target: under 200 milliseconds (this is the metric that replaced FID in 2024).
Poor INP is caused by long JavaScript tasks blocking the main thread β including heavy re-renders. When the browser is busy running your code, it can't respond to clicks, taps, or keystrokes, so the next paint is delayed. The concurrent hooks from Strategy #7 (useTransition, useDeferredValue) are one of your best INP tools because they let expensive renders yield to urgent input.
π§ INP Optimization Strategies
// 1. Break up long tasks
// β Bad: Long synchronous task blocks thread
function processLargeDataset(data: Item[]) {
const results = data.map(item => {
// Complex processing...
return expensiveCalculation(item);
});
displayResults(results);
}
// β
Good: Break into chunks with requestIdleCallback
async function processLargeDataset(data: Item[]) {
const results: ProcessedItem[] = [];
const chunkSize = 100;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
// Process chunk
const processedChunk = chunk.map(item => expensiveCalculation(item));
results.push(...processedChunk);
// Yield to browser
await new Promise(resolve => {
if ('requestIdleCallback' in window) {
requestIdleCallback(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
displayResults(results);
}
// 2. Use Web Workers for heavy computations
// worker.ts
self.addEventListener('message', (e) => {
const result = expensiveCalculation(e.data);
self.postMessage(result);
});
// Component.tsx
function DataProcessor() {
const [result, setResult] = useState<Result | null>(null);
useEffect(() => {
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage(largeDataset);
worker.onmessage = (e) => {
setResult(e.data);
worker.terminate();
};
return () => worker.terminate();
}, []);
return <ResultDisplay data={result} />;
}
// 3. Defer non-critical JavaScript
function App() {
useEffect(() => {
// Load analytics after page is interactive
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('./analytics').then(({ initAnalytics }) => {
initAnalytics();
});
});
}
}, []);
return <YourApp />;
}
β INP Quick Wins
- Wrap expensive state updates in
useTransition/useDeferredValue - Code split to reduce JavaScript execution time
- Break up long tasks into smaller chunks (yield to the browser)
- Use Web Workers for CPU-intensive work
- Defer third-party scripts (analytics, chat widgets)
- Remove unused JavaScript and use modern, smaller libraries
π― Improving Cumulative Layout Shift (CLS)
CLS measures visual stability. Target: < 0.1
Layout shifts happen when visible page content moves unexpectedly. This is incredibly frustrating for users!
π§ CLS Optimization Strategies
// 1. Always include size attributes for images
// β Bad: Image loads and pushes content down
<img src="/photo.jpg" alt="Photo" />
// β
Good: Space reserved, no layout shift
<img src="/photo.jpg" alt="Photo" width={800} height={600} />
// β
Also good: aspect-ratio
<img
src="/photo.jpg"
alt="Photo"
style={{ width: '100%', aspectRatio: '16/9' }}
/>
// 2. Reserve space for dynamic content
// β Bad: Ad or widget loads and shifts content
function Sidebar() {
const [ad, setAd] = useState<Ad | null>(null);
useEffect(() => {
loadAd().then(setAd);
}, []);
return (
<aside>
{ad && <AdWidget ad={ad} />} {/* Suddenly appears! */}
<SidebarContent />
</aside>
);
}
// β
Good: Reserve space with skeleton
function Sidebar() {
const [ad, setAd] = useState<Ad | null>(null);
useEffect(() => {
loadAd().then(setAd);
}, []);
return (
<aside>
<div style={{ minHeight: '250px' }}>
{ad ? (
<AdWidget ad={ad} />
) : (
<AdSkeleton /> {/* Placeholder with same size */}
)}
</div>
<SidebarContent />
</aside>
);
}
// 3. Avoid inserting content above existing content
// β Bad: New message appears above and shifts everything down
function ChatMessages({ messages }: { messages: Message[] }) {
return (
<div>
{messages.map(msg => (
<Message key={msg.id} message={msg} />
))}
</div>
);
}
// β
Good: Scroll to keep position stable
function ChatMessages({ messages }: { messages: Message[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const prevHeightRef = useRef(0);
useLayoutEffect(() => {
if (containerRef.current) {
const newHeight = containerRef.current.scrollHeight;
const heightDiff = newHeight - prevHeightRef.current;
// Adjust scroll to maintain position
if (heightDiff > 0) {
containerRef.current.scrollTop += heightDiff;
}
prevHeightRef.current = newHeight;
}
}, [messages]);
return (
<div ref={containerRef}>
{messages.map(msg => (
<Message key={msg.id} message={msg} />
))}
</div>
);
}
// 4. Use font-display for web fonts
// In your CSS
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Show fallback font immediately */
}
// Or preload fonts
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/custom.woff2"
crossOrigin="anonymous"
/>
β CLS Quick Wins
- Set dimensions for all images and videos
- Reserve space for ads and embeds
- Use skeleton screens for loading states
- Avoid inserting content above existing content
- Use
font-display: swapfor web fonts - Preload critical fonts
- Avoid animations that change layout
π Monitoring Web Vitals in Production
It's crucial to monitor real user performance, not just lab tests:
// Install web-vitals library
// npm install web-vitals
// web-vitals v4+ exports onINP β onFID was removed when FID was retired.
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';
// Send metrics to analytics
function sendToAnalytics(metric: Metric) {
// Send to your analytics service
const body = JSON.stringify(metric);
// Use `navigator.sendBeacon()` if available, falling back to `fetch()`
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', { body, method: 'POST', keepalive: true });
}
}
// Track all Core Web Vitals
export function initWebVitals() {
onCLS(sendToAnalytics);
onINP(sendToAnalytics); // replaces onFID
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}
// Usage in your app
function App() {
useEffect(() => {
// Vite exposes the mode via import.meta.env β no process.env needed
if (import.meta.env.PROD) {
initWebVitals();
}
}, []);
return <YourApp />;
}
// Advanced: Track by route
import { useLocation } from 'react-router-dom';
function WebVitalsReporter() {
const location = useLocation();
useEffect(() => {
const sendMetric = (metric: Metric) => {
sendToAnalytics({
...metric,
route: location.pathname,
} as Metric);
};
onCLS(sendMetric);
onINP(sendMetric);
onLCP(sendMetric);
}, [location]);
return null;
}
π‘ Real User Monitoring (RUM) Services
- Google Analytics 4 - Built-in Web Vitals reporting
- Vercel Analytics - Automatic for Vercel deployments
- Sentry Performance - Detailed performance monitoring
- New Relic - Enterprise monitoring solution
- Datadog RUM - Comprehensive monitoring
ποΈ Practice Exercises
Exercise 1: Profile and Optimize a Slow Component
Objective: Use React DevTools Profiler to find and fix performance issues
Task: Create a product listing page that renders slowly, profile it, then optimize it.
π‘ Hints
- Create a ProductList component with 500+ products
- Add filters and sorting that cause re-renders
- Use React DevTools Profiler to record interactions
- Look for components rendering unnecessarily
- Apply React.memo, useMemo, and useCallback
- Consider virtualization for the long list
β Solution Approach
// Start with slow version
function ProductList({ products }: Props) {
const [filter, setFilter] = useState('');
const [sort, setSort] = useState('name');
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
const sorted = [...filtered].sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
});
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{sorted.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// Optimize with memoization and virtualization
const ProductCard = React.memo(({ product }: Props) => {
return <div>{product.name} - ${product.price}</div>;
});
function ProductList({ products }: Props) {
const [filter, setFilter] = useState('');
const [sort, setSort] = useState('name');
const filtered = useMemo(() =>
products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
),
[products, filter]
);
const sorted = useMemo(() =>
[...filtered].sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
}),
[filtered, sort]
);
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<FixedSizeList
height={600}
itemCount={sorted.length}
itemSize={80}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<ProductCard product={sorted[index]} />
</div>
)}
</FixedSizeList>
</div>
);
}
Exercise 2: Implement Route-Based Code Splitting
Objective: Split a multi-route application into separate bundles
Task: Take an application with 5+ routes and implement lazy loading for each route.
π‘ Hints
- Use React.lazy() and Suspense
- Create a loading component for the Suspense fallback
- Check bundle sizes before and after
- Implement prefetching on navigation hover
- Test that routes load correctly
β Solution Approach
// Before: All routes load upfront
import Home from './pages/Home';
import About from './pages/About';
import Products from './pages/Products';
import Contact from './pages/Contact';
import Admin from './pages/Admin';
// After: Lazy load routes
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Products = lazy(() => import('./pages/Products'));
const Contact = lazy(() => import('./pages/Contact'));
const Admin = lazy(() => import('./pages/Admin'));
// Loading component
function LoadingScreen() {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
<p>Loading...</p>
</div>
);
}
// App with lazy routes
function App() {
return (
<BrowserRouter>
<Navigation />
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
<Route path="/contact" element={<Contact />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
// Add prefetching
function Navigation() {
const preloadAbout = () => import('./pages/About');
const preloadProducts = () => import('./pages/Products');
return (
<nav>
<Link to="/">Home</Link>
<Link
to="/about"
onMouseEnter={preloadAbout}
onFocus={preloadAbout}
>
About
</Link>
<Link
to="/products"
onMouseEnter={preloadProducts}
onFocus={preloadProducts}
>
Products
</Link>
</nav>
);
}
Exercise 3: Optimize Images for Performance
Objective: Implement responsive, lazy-loaded images with modern formats
Task: Create an image gallery with proper optimization techniques.
π‘ Hints
- Use multiple image sizes (small, medium, large)
- Implement lazy loading (native or Intersection Observer)
- Add proper width and height attributes
- Use WebP with fallbacks
- Measure CLS score before and after
β Solution Approach
interface GalleryImage {
id: string;
alt: string;
small: string;
medium: string;
large: string;
width: number;
height: number;
}
function Gallery({ images }: { images: GalleryImage[] }) {
return (
<div className="gallery">
{images.map((image, index) => (
<picture key={image.id}>
<source
type="image/webp"
srcSet={`
${image.small} 480w,
${image.medium} 800w,
${image.large} 1200w
`}
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
/>
<img
src={image.medium}
srcSet={`
${image.small} 480w,
${image.medium} 800w,
${image.large} 1200w
`}
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
alt={image.alt}
width={image.width}
height={image.height}
loading={index < 6 ? 'eager' : 'lazy'}
style={{
aspectRatio: `${image.width}/${image.height}`,
width: '100%',
height: 'auto',
}}
/>
</picture>
))}
</div>
);
}
Exercise 4: Improve Core Web Vitals
Objective: Take a slow app and improve all three Core Web Vitals metrics
Task: Run Lighthouse on an app, identify issues, and fix them to achieve good scores.
π‘ Hints
- Run Lighthouse in Chrome DevTools
- Focus on the three Core Web Vitals
- Implement recommendations from Lighthouse report
- Measure before and after scores
- Test on real device with network throttling
β Solution Checklist
- For LCP:
- Preload hero image
- Use WebP format
- Implement CDN
- Remove render-blocking resources
- For INP:
- Wrap expensive updates in useTransition / useDeferredValue
- Code split routes and defer third-party scripts
- Break up long tasks so the main thread can respond
- Reduce JavaScript execution time
- For CLS:
- Add dimensions to all images
- Reserve space for ads/embeds
- Use font-display: swap
- Avoid inserting content above viewport
π Knowledge Check
Question 1: What does React.memo do?
Show Answer
Answer: React.memo is a higher-order component that memoizes a component's output. It prevents re-renders when props haven't changed by doing a shallow comparison of props. This is useful for optimizing expensive components that receive the same props frequently.
Question 2: What's the difference between useMemo and useCallback?
Show Answer
Answer:
- useMemo: Memoizes the result of a computation. Returns a cached value.
- useCallback: Memoizes a function itself. Returns a cached function reference.
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)
Question 3: What are the three Core Web Vitals?
Show Answer
Answer:
- LCP (Largest Contentful Paint): Loading performance β should be under 2.5s
- INP (Interaction to Next Paint): Responsiveness β should be under 200ms. INP replaced FID (First Input Delay) as a Core Web Vital in March 2024.
- CLS (Cumulative Layout Shift): Visual stability β should be under 0.1
Question 4: When should you use virtualization/windowing?
Show Answer
Answer: Use virtualization when rendering long lists (typically 100+ items) where only a portion is visible at once. Virtualization only renders items in the viewport, dramatically improving performance for lists with hundreds or thousands of items. Libraries like react-window or @tanstack/react-virtual make this easy.
Question 5: What's the difference between debouncing and throttling?
Show Answer
Answer:
- Debouncing: Waits for a pause in events before executing. Fires after user stops triggering events. Good for search inputs.
- Throttling: Executes at most once per time interval regardless of how many times it's triggered. Good for scroll/resize handlers.
Question 6: Why is code splitting important?
Show Answer
Answer: Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. This reduces initial bundle size, leading to faster initial page loads and improved Time to Interactive. Users only download the code they need, when they need it. Route-based splitting is the most effective strategy.
Question 7: What attributes should every image have to prevent layout shift?
Show Answer
Answer: Every image should have:
widthandheightattributes (even if styled with CSS)- Or use
aspect-ratioCSS property
This allows the browser to reserve space before the image loads, preventing Cumulative Layout Shift (CLS).
Question 8: When should you NOT use React.memo?
Show Answer
Answer: Don't use React.memo when:
- The component is already fast to render
- Props change frequently (memoization overhead not worth it)
- Props are always different objects/functions (needs useMemo/useCallback)
- The component has very few instances
React.memo adds overhead, so only use it when profiling shows it helps.
π― Lesson Summary
Congratulations! You've learned the essential techniques for building fast, performant React applications with TypeScript. Let's recap the key concepts:
π Key Takeaways
- Measure First: Use the React DevTools Profiler, Lighthouse, and the web-vitals library to find real issues
- Let the compiler help: The React Compiler auto-memoizes; reach for manual React.memo, useMemo, and useCallback only where the profiler points you
- Stay responsive: useTransition and useDeferredValue keep the UI snappy during expensive renders
- Split Code: Lazy load routes and heavy components; virtualize long lists with TanStack Virtual or react-window
- Optimize Images: Use modern formats, responsive images, and lazy loading
- Improve Web Vitals: Focus on LCP, INP, and CLS for a better user experience
- Monitor Production: Track real user metrics to catch regressions
β Performance Optimization Workflow
- Establish Baseline: Measure current performance with Lighthouse and React Profiler
- Identify Bottlenecks: Find the slowest components and largest bundles
- Apply Optimizations: Use the appropriate technique for each issue
- Measure Impact: Verify improvements with hard numbers
- Monitor Continuously: Track real user metrics in production
- Set Budgets: Define performance budgets and prevent regressions
π Next Steps
Now that you understand performance optimization, you're ready to:
- Learn advanced TypeScript patterns in Lesson 10.2
- Explore accessibility best practices in Lesson 10.3
- Master build and deployment strategies in Lesson 10.4
- Apply these techniques to your projects
β οΈ Remember
Premature optimization is the root of all evil. Always:
- Profile before optimizing
- Focus on what matters most to users
- Measure the impact of your changes
- Balance performance with code maintainability