πŸš€ 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:

  1. Measure - Use React DevTools and browser tools to identify slow parts
  2. Analyze - Understand what's causing performance problems
  3. Optimize - Apply targeted fixes that actually make a difference
  4. Verify - Confirm improvements with measurable metrics
graph LR A[Slow App] --> B[Measure Performance] B --> C[Identify Bottlenecks] C --> D[Apply Optimizations] D --> E[Verify Improvements] E --> F[Fast App! πŸŽ‰] style A fill:#ffcdd2 style F fill:#c8e6c9 style B fill:#fff9c4 style C fill:#fff9c4 style D fill:#b3e5fc style E fill:#b3e5fc

πŸ’‘ 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

  1. React DevTools Profiler - Measures React component render performance
  2. Chrome DevTools Performance Tab - Low-level browser performance profiling
  3. Lighthouse - Automated performance audits and recommendations
  4. Web Vitals - Real user experience metrics
  5. 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:

graph TD A[Core Web Vitals] --> B[LCP
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:

Once installed, you'll see two new tabs in your DevTools: "Components" and "Profiler".

πŸ“Š Using the Profiler

βœ… Step-by-Step: Profiling Your App

  1. Open React DevTools and click the "Profiler" tab
  2. Click the blue record button (⏺️)
  3. Interact with your app (the actions you want to measure)
  4. Click the record button again to stop
  5. Analyze the flame graph and commit data

πŸ”₯ Understanding the Flame Graph

The Profiler displays a flame graph showing your component tree and render times:

graph TD A[App - 45ms] --> B[Header - 5ms] A --> C[MainContent - 38ms] A --> D[Footer - 2ms] C --> E[Sidebar - 3ms] C --> F[ArticleList - 35ms] F --> G[Article - 8ms] F --> H[Article - 9ms] F --> I[Article - 10ms] F --> J[Article - 8ms] style A fill:#ffcdd2 style C fill:#ffcdd2 style F fill:#ffcdd2 style G fill:#fff9c4 style H fill:#fff9c4 style I fill:#ffab91 style J fill:#fff9c4 style B fill:#c8e6c9 style D fill:#c8e6c9 style E fill:#c8e6c9

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:

mindmap root((Performance
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:

⚑ Interactive: Re-render Cascade Visualizer

Watch components flash when they re-render. See how React.memo prevents unnecessary renders.

App (Parent)
count: 0
Renders: 0
Counter
props: count
Renders: 0
Header
props: title
Renders: 0
Footer
props: year
Renders: 0
Total Renders
0
Renders Saved
0
Efficiency
0%
πŸ’‘ Without React.memo:
Every time the parent updates its state, all children re-renderβ€”even Header and Footer whose props never change. This is wasted work!
// 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>;
}
sequenceDiagram participant User participant Input participant Debounce participant API User->>Input: Types "h" Input->>Debounce: Start timer User->>Input: Types "e" Debounce->>Debounce: Cancel previous timer Input->>Debounce: Start new timer User->>Input: Types "l" Debounce->>Debounce: Cancel previous timer Input->>Debounce: Start new timer User->>Input: Types "l" Debounce->>Debounce: Cancel previous timer Input->>Debounce: Start new timer User->>Input: Types "o" Debounce->>Debounce: Cancel previous timer Input->>Debounce: Start new timer Note over Debounce: User stops typing
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:

graph TD A[Total Bundle: 850KB] --> B[Your Code: 250KB] A --> C[node_modules: 600KB] B --> B1[Components: 120KB] B --> B2[Utils: 80KB] B --> B3[Assets: 50KB] C --> C1[React: 140KB] C --> C2[date-fns: 200KB WARNING] C --> C3[lodash: 150KB WARNING] C --> C4[Other: 110KB] style A fill:#ffcdd2 style C2 fill:#ffcdd2 style C3 fill:#ffcdd2 style B fill:#c8e6c9 style C1 fill:#c8e6c9

🎯 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

  1. Use a CDN for faster asset delivery
  2. Optimize server response times (TTFB < 600ms)
  3. Preload the LCP image
  4. Use modern image formats (WebP, AVIF)
  5. Remove render-blocking JavaScript and CSS
  6. 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

  1. Code split to reduce JavaScript execution time
  2. Break up long tasks into smaller chunks
  3. Use Web Workers for CPU-intensive work
  4. Defer third-party scripts (analytics, chat widgets)
  5. Remove unused JavaScript
  6. 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

  1. Set dimensions for all images and videos
  2. Reserve space for ads and embeds
  3. Use skeleton screens for loading states
  4. Avoid inserting content above existing content
  5. Use font-display: swap for web fonts
  6. Preload critical fonts
  7. 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:

  1. LCP (Largest Contentful Paint): Loading performance - should be < 2.5s
  2. FID (First Input Delay): Interactivity - should be < 100ms
  3. 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:

  • width and height attributes (even if styled with CSS)
  • Or use aspect-ratio CSS 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
mindmap root((Performance
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

  1. Establish Baseline: Measure current performance with Lighthouse and React Profiler
  2. Identify Bottlenecks: Find the slowest components and largest bundles
  3. Apply Optimizations: Use the appropriate technique for each issue
  4. Measure Impact: Verify improvements with hard numbers
  5. Monitor Continuously: Track real user metrics in production
  6. 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