π Lesson 10.1: Performance Optimization
Master the art of building fast, efficient React applications. Learn how to measure performance, identify bottlenecks, and apply optimization techniques that make a real difference.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Use React DevTools Profiler to identify performance issues
- Understand and measure render performance with Core Web Vitals
- Implement code splitting for faster initial loads
- Optimize bundle size and reduce JavaScript payload
- Apply image optimization techniques and best practices
- Improve Largest Contentful Paint, First Input Delay, 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 start by replacing the engineβyou'd first identify what's actually slow, measure it, and make targeted improvements. The same principle applies to React applications.
π 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 - Real user experience metrics
- 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:
Largest Contentful Paint] A --> C[FID
First Input Delay] A --> D[CLS
Cumulative Layout Shift] B --> B1[Loading Performance
How fast main content appears
Goal: < 2.5 seconds] C --> C1[Interactivity
How quickly page responds
Goal: < 100 milliseconds] D --> D1[Visual Stability
How much page shifts
Goal: < 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
π‘ First Input Delay (FID)
What it measures: Time from when a user first interacts (click, tap, key press) to when the browser can actually respond.
Why it matters: Users get frustrated when buttons don't respond immediately.
Good score: Less than 100 milliseconds
Common issues: Long JavaScript tasks blocking the main thread
π‘ 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:
// Create a simple performance logging utility
interface PerformanceMetrics {
lcp: number;
fid: number;
cls: number;
ttfb: number; // Time to First Byte
fcp: number; // First Contentful Paint
}
function measureWebVitals(): PerformanceMetrics {
const metrics: Partial<PerformanceMetrics> = {};
// Measure LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
metrics.lcp = lastEntry.renderTime || lastEntry.loadTime;
console.log('LCP:', metrics.lcp);
}).observe({ entryTypes: ['largest-contentful-paint'] });
// Measure FID
new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry: any) => {
metrics.fid = entry.processingStart - entry.startTime;
console.log('FID:', metrics.fid);
});
}).observe({ entryTypes: ['first-input'] });
// Measure CLS
let clsValue = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
clsValue += entry.value;
}
}
metrics.cls = clsValue;
console.log('CLS:', metrics.cls);
}).observe({ entryTypes: ['layout-shift'] });
return metrics as PerformanceMetrics;
}
// Use in your app
function App() {
useEffect(() => {
const metrics = measureWebVitals();
// Send to analytics service
// analytics.track('web-vitals', metrics);
}, []);
return <YourApp />;
}
β οΈ 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:
In this visualization:
- Green = Fast (good!)
- Yellow/Orange = Slow (investigate)
- Red = Very slow (optimize now!)
π― 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:
Bottlenecks)) Rendering Issues Too many re-renders Large component trees Expensive render logic Inline function creation Data Issues Large lists without virtualization Unoptimized state updates Expensive computations Memory leaks Bundle Issues Large JavaScript bundles No code splitting Unused dependencies Duplicate code Network Issues Unoptimized images No caching strategy Blocking resources Too many requests
π 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);
π‘ 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 virtualization libraries that only render visible items:
- react-window - Lightweight, great for simple lists
- react-virtualized - More features, larger bundle
- @tanstack/react-virtual - Modern, flexible API
π 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.
π― Strategy #1: React.memo
React.memo is a higher-order component that prevents unnecessary re-renders by memoizing the component output:
// 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!
π― 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 |
π¦ 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 First Input Delay (FID)
FID measures interactivity. Target: < 100 milliseconds
FID is caused by long JavaScript tasks blocking the main thread. When the browser is busy executing JavaScript, it can't respond to user interactions.
π§ FID 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 />;
}
β FID Quick Wins
- Code split to reduce JavaScript execution time
- Break up long tasks into smaller chunks
- Use Web Workers for CPU-intensive work
- Defer third-party scripts (analytics, chat widgets)
- Remove unused JavaScript
- 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
import { onCLS, onFID, onLCP, onFCP, onTTFB } 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);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}
// Usage in your app
function App() {
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
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,
});
};
onCLS(sendMetric);
onFID(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 FID:
- Code split routes
- Defer third-party scripts
- Break up long tasks
- 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 < 2.5s
- FID (First Input Delay): Interactivity - should be < 100ms
- CLS (Cumulative Layout Shift): Visual stability - should be < 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 React DevTools Profiler, Lighthouse, and Web Vitals to identify real issues
- Optimize Renders: React.memo, useMemo, and useCallback prevent unnecessary work
- Split Code: Lazy load routes and heavy components to reduce bundle size
- Optimize Images: Use modern formats, responsive images, and lazy loading
- Improve Web Vitals: Focus on LCP, FID, and CLS for better user experience
- Monitor Production: Track real user metrics to catch regressions
Optimization)) Measurement React DevTools Profiler Lighthouse Web Vitals Bundle Analyzer Rendering React.memo useMemo useCallback Virtualization Code Code splitting Tree shaking Lazy loading Prefetching Assets Image optimization Modern formats Lazy loading CDN Monitoring Real user metrics Core Web Vitals Error tracking Performance budgets
β 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