πŸš€ Lesson 10.1: Performance Optimization

Master the art of building fast, efficient React applications. In the React 19 era the compiler quietly memoizes a lot for you, so the real skill is knowing what to measure, how to read the numbers, and which lever to pull when the numbers are bad. Let's build that instinct.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Use the React DevTools Profiler to see which components render, how long they take, and why they re-rendered
  • Measure real-user performance with the Core Web Vitals β€” LCP, INP, and CLS
  • Explain what the React Compiler automates, and when manual memo / useMemo / useCallback still earn their keep
  • Keep the UI responsive under load with useTransition and useDeferredValue
  • Split code with React.lazy + <Suspense> and virtualize long lists
  • Shrink your bundle, optimize images, and improve Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift

Estimated Time: 75-90 minutes

Project: Profile and optimize a real React application

In This Lesson

πŸ“– Introduction to Performance Optimization

Welcome to Module 10! You've learned a tremendous amount about building React applications with TypeScript. Now it's time to make them fast. Performance optimization is what separates good applications from great ones.

Performance isn't just about making things fasterβ€”it's about creating better user experiences, improving accessibility, and even helping your application rank better in search engines. A slow app loses users, reduces engagement, and can cost your business real money.

βœ… The Performance Mindset

Think of performance optimization like tuning a car. You wouldn't rip out the engine on day oneβ€”you'd first find out what's actually slow, measure it, and make one targeted change at a time. The same discipline applies to React: measure, then fix, then measure again. A guess about what's slow is almost always wrong; a profiler is almost always right.

πŸ’‘ A word about the React Compiler (React 19)

You may have heard that React 19 ships with an optimizing compiler that memoizes your components and values automaticallyβ€”so you might wonder whether hand-written memo, useMemo, and useCallback are still worth learning. They absolutely are. The compiler removes a lot of the busywork, but understanding what it's doing (and why a render happens at all) is exactly what lets you read a profile, spot the outlier, and know which lever to reach for. We'll call out where the compiler changes the day-to-day advice as we go.

πŸ“Š What We'll Cover in This Lesson

This lesson focuses on practical, real-world performance optimization. We'll learn to:

  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 - The tiny library Google publishes for measuring real-user Core Web Vitals
  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:

⚠️ Heads up: FID is retired

If you learned React a few years ago, you'll remember First Input Delay (FID) as the third Core Web Vital. In March 2024 Google replaced FID with Interaction to Next Paint (INP). INP is a strictly better metric: FID only measured the delay before the first interaction, while INP measures the responsiveness of every interaction across the whole visit. Tools, docs, and the web-vitals library have all moved to INP, so that's what we teach here.

graph TD A["Core Web Vitals"] --> B["LCP
Largest Contentful Paint"] A --> C["INP
Interaction to Next Paint"] A --> D["CLS
Cumulative Layout Shift"] B --> B1["Loading Performance
How fast main content appears
Goal: under 2.5 seconds"] C --> C1["Responsiveness
How quickly every interaction paints
Goal: under 200 milliseconds"] D --> D1["Visual Stability
How much the page shifts
Goal: under 0.1"] style A fill:#667eea,color:#fff style B fill:#c8e6c9 style C fill:#c8e6c9 style D fill:#c8e6c9 style B1 fill:#e8f5e9 style C1 fill:#e8f5e9 style D1 fill:#e8f5e9

🎯 Understanding Each Metric

πŸ’‘ Largest Contentful Paint (LCP)

What it measures: How long it takes for the largest visible element (hero image, heading, text block) to appear on screen.

Why it matters: Users perceive the page as loaded when they see the main content.

Good score: Less than 2.5 seconds

Common issues: Large images, render-blocking JavaScript, slow server response times

πŸ’‘ Interaction to Next Paint (INP)

What it measures: Across the whole visit, how long the slowest interactions take from the user's action (click, tap, key press) to the next frame the browser paints in response.

Why it matters: Users get frustrated when a button click, a menu open, or a keystroke feels laggy β€” INP captures that "did anything happen?" feeling that FID missed.

Good score: Less than 200 milliseconds (needs work: 200–500ms; poor: over 500ms)

Common issues: Long JavaScript tasks blocking the main thread, heavy re-renders triggered by an interaction

πŸ’‘ Cumulative Layout Shift (CLS)

What it measures: How much visible content shifts around as the page loads.

Why it matters: Unexpected layout shifts cause users to click the wrong things and create a janky experience.

Good score: Less than 0.1

Common issues: Images without dimensions, web fonts causing text to shift, dynamic content insertion

πŸ” Using Lighthouse for Quick Audits

Lighthouse is built into Chrome DevTools and provides instant performance insights. Let's learn how to use it:

// To run a Lighthouse audit:
// 1. Open Chrome DevTools (F12 or Cmd+Option+I)
// 2. Click the "Lighthouse" tab
// 3. Select "Performance" category
// 4. Click "Analyze page load"

// Lighthouse gives you a score (0-100) and specific recommendations
// like:
// - "Serve images in next-gen formats (WebP)"
// - "Reduce unused JavaScript"
// - "Properly size images"
// - "Eliminate render-blocking resources"

βœ… Pro Tip: Run Multiple Tests

Performance can vary based on network conditions, CPU load, and other factors. Always run Lighthouse at least 3 times and look at the average scores. Test on both desktop and mobile, and use "Incognito mode" to avoid browser extensions affecting results.

πŸ“ˆ Establishing a Performance Baseline

Before optimizing anything, establish baseline metrics for your application:

// The easiest, most accurate way to read your baseline is the
// official web-vitals library β€” it handles all the tricky observer
// edge cases (including INP) for you.
//   npm install web-vitals
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';

// Log each metric to the console as it becomes available.
function logMetric(metric: Metric): void {
  // metric.name is 'CLS' | 'INP' | 'LCP' | 'FCP' | 'TTFB'
  // metric.rating is 'good' | 'needs-improvement' | 'poor'
  console.log(`${metric.name}: ${metric.value.toFixed(1)} (${metric.rating})`);
}

// Wire them up once, as early as possible.
export function reportBaseline(): void {
  onLCP(logMetric);   // Largest Contentful Paint
  onINP(logMetric);   // Interaction to Next Paint (replaced FID)
  onCLS(logMetric);   // Cumulative Layout Shift
  onFCP(logMetric);   // First Contentful Paint
  onTTFB(logMetric);  // Time to First Byte
}

// Use in your app
function App() {
  useEffect(() => {
    reportBaseline();
  }, []);

  return <YourApp />;
}

Rolling your own PerformanceObserver is a great way to learn how these metrics are captured, but INP in particular is fiddly to measure by hand. Reach for the library in real projects β€” it's about 2 KB and Google keeps it in lock-step with the official definitions.

⚠️ Development vs Production

React's development build is significantly slower than production due to warnings, stack traces, and other debugging features. Always test performance with a production build!

npm run build
npm run preview  # or serve the build folder

πŸ”¬ React DevTools Profiler

The React DevTools Profiler is your best friend for identifying React-specific performance issues. It shows exactly which components are rendering, how long they take, and why they re-rendered.

πŸš€ Installing React DevTools

React DevTools is available as a browser extension:

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

The flame graph is colour-coded by how long each component took to render:

  • 🟩 Green = Fast β€” nothing to see here (good!)
  • 🟧 Yellow / Orange = Slow β€” worth a closer look
  • πŸŸ₯ Red = Very slow β€” this is where you start optimizing

The width of each bar is its render time, so the widest, reddest bars are your biggest wins. In the tree above, ArticleList (and the App and MainContent it sits inside) dominate the frame β€” that's your prime suspect.

🎯 What the Profiler Shows

For each component render, you can see:

Metric What It Means What to Look For
Render duration How long the component took to render Components taking >16ms (one frame at 60fps)
Why did this render? What caused the component to re-render Unnecessary renders (props didn't actually change)
Render count How many times it rendered during recording Components rendering too frequently
Committed at When the render was committed to DOM Patterns in timing (e.g., all renders at once)

πŸ’‘ Practical Example: Finding Slow Components

Let's profile a real application and identify issues:

// Example: A product list that's rendering slowly
interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
  rating: number;
}

// 😱 Slow component - re-renders unnecessarily
function ProductCard({ product }: { product: Product }) {
  console.log('ProductCard rendering:', product.name);
  
  // Expensive calculation on every render
  const formattedPrice = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(product.price);
  
  // Complex rendering logic
  const stars = Array.from({ length: 5 }, (_, i) => {
    return i < Math.floor(product.rating) ? 'β˜…' : 'β˜†';
  }).join('');
  
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{formattedPrice}</p>
      <p>{stars} ({product.rating})</p>
      <p>{product.description}</p>
    </div>
  );
}

function ProductList({ products }: { products: Product[] }) {
  const [sortBy, setSortBy] = useState<'name' | 'price'>('name');
  
  // Sorting happens on every render!
  const sortedProducts = [...products].sort((a, b) => {
    if (sortBy === 'name') {
      return a.name.localeCompare(b.name);
    }
    return a.price - b.price;
  });
  
  return (
    <div>
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
        <option value="name">Sort by Name</option>
        <option value="price">Sort by Price</option>
      </select>
      
      {sortedProducts.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

⚠️ What the Profiler Would Show

When you profile this component:

  • ProductList renders on every sort change βœ… (expected)
  • Every ProductCard re-renders even though product data didn't change 😱 (wasteful!)
  • The sorting calculation runs on every render 😱 (unnecessary!)
  • Number formatting happens repeatedly for the same prices 😱 (expensive!)

We'll learn how to fix these issues in the next section!

πŸ” Analyzing "Why Did This Render?"

One of the Profiler's most useful features is showing why a component rendered:

// Click on any component in the Profiler to see:

// βœ… Good reasons to render:
// - "State changed" - Component's own state updated
// - "Props changed" - Parent passed different props
// - "Parent rendered" - But props actually changed

// 😱 Bad reasons (opportunities to optimize):
// - "Parent rendered" - But props are the same
// - "Context changed" - But component doesn't use the changed value
// - "Props changed" - But the "change" is a new object with same values

βœ… Pro Tip: The "Ranked" View

Switch to the "Ranked" chart view in the Profiler to see components sorted by render time. This instantly shows you where to focus your optimization effortsβ€”start with the slowest components at the top!

πŸ” Identifying Performance Bottlenecks

Now that we know how to measure performance, let's learn to identify the most common bottlenecks in React applications. Understanding what's slow is the first step to making it fast.

🎯 Common Performance Bottlenecks

React applications typically suffer from these performance issues:

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);

βœ… The React Compiler does most of this for you

The hand-written useMemo around config, the useCallback around handleClick, the React.memo wrapper β€” in a project with the React Compiler enabled, you often write none of them. The compiler analyses your components at build time and inserts equivalent memoization automatically, so the "new object / new function every render" trap mostly disappears.

So why learn the manual versions? Because the compiler needs your components to follow the Rules of React to kick in, it doesn't cover every case (third-party code, opt-outs, gradual adoption), and the mental model β€” "a new reference makes a memoized child re-render" β€” is exactly what you need to read a profile. Treat manual memoization as the tool you reach for when the profiler tells you to, not a reflex on every prop.

πŸ’‘ How to Spot This Issue

In the Profiler, look for:

  • Components showing "Parent rendered" as the render reason
  • Props that show as "changed" but have the same values
  • Many components rendering simultaneously when only one should

🐌 Bottleneck #2: Expensive Computations

Calculations that run on every render can severely impact performance:

// 😱 Expensive computation runs every render
function DataDashboard({ data }: { data: DataPoint[] }) {
  // This sorts and filters thousands of items on EVERY RENDER!
  const processedData = data
    .filter(point => point.value > 0)
    .sort((a, b) => b.value - a.value)
    .slice(0, 100);
  
  const average = processedData.reduce((sum, point) => sum + point.value, 0) / processedData.length;
  
  return (
    <div>
      <h2>Average: {average.toFixed(2)}</h2>
      <DataTable data={processedData} />
    </div>
  );
}

// βœ… Memoized version - only recalculates when data changes
function DataDashboard({ data }: { data: DataPoint[] }) {
  const processedData = useMemo(() => {
    console.log('Computing processed data...');
    return data
      .filter(point => point.value > 0)
      .sort((a, b) => b.value - a.value)
      .slice(0, 100);
  }, [data]); // Only recompute when data changes
  
  const average = useMemo(() => {
    console.log('Computing average...');
    return processedData.reduce((sum, point) => sum + point.value, 0) / processedData.length;
  }, [processedData]);
  
  return (
    <div>
      <h2>Average: {average.toFixed(2)}</h2>
      <DataTable data={processedData} />
    </div>
  );
}

βœ… Rule of Thumb: When to useMemo

Use useMemo when:

  • The computation is expensive (loops through large arrays, complex math)
  • The result is used as a prop to child components
  • The computation happens frequently but dependencies change rarely

Don't use useMemo for simple calculationsβ€”it adds overhead!

🐌 Bottleneck #3: Large Lists

Rendering thousands of items is expensive, even if they're simple:

// 😱 Renders 10,000 items - page becomes unresponsive
function HugeList({ items }: { items: Item[] }) {
  return (
    <div className="list">
      {items.map(item => (
        <ListItem key={item.id} item={item} />
      ))}
    </div>
  );
}

// βœ… Solution 1: Virtualization (only render visible items)
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }: { items: Item[] }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <ListItem item={items[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

// βœ… Solution 2: Pagination
function PaginatedList({ items }: { items: Item[] }) {
  const [page, setPage] = useState(0);
  const itemsPerPage = 50;
  
  const paginatedItems = useMemo(() => {
    const start = page * itemsPerPage;
    return items.slice(start, start + itemsPerPage);
  }, [items, page]);
  
  return (
    <div>
      {paginatedItems.map(item => (
        <ListItem key={item.id} item={item} />
      ))}
      <Pagination
        page={page}
        totalPages={Math.ceil(items.length / itemsPerPage)}
        onPageChange={setPage}
      />
    </div>
  );
}

πŸ’‘ Virtualization Libraries

For large lists, use a virtualization library that renders only the visible items:

  • @tanstack/react-virtual - The modern default. Headless (you own the markup), tiny, and works for lists, grids, and tables with fixed or variable sizes.
  • react-window - The classic lightweight choice. Ready-made <FixedSizeList> / <VariableSizeList> components; great when you want batteries included. (Note: react-virtualized is its older, heavier predecessor β€” prefer react-window or TanStack Virtual for new code.)

We'll show both APIs later in this lesson so you can recognize either in the wild.

πŸ“Š Performance Checklist

Use this checklist when investigating performance issues:

βœ… Check What to Look For Tool to Use
Rendering frequency Components rendering too often React DevTools Profiler
Render duration Slow component renders (>16ms) React DevTools Profiler
JavaScript execution Long tasks blocking the main thread Chrome DevTools Performance
Bundle size Large JavaScript files Bundle Analyzer
Network requests Slow or blocking resources Chrome Network tab
Images Unoptimized or oversized images Lighthouse
Memory leaks Memory usage growing over time Chrome Memory Profiler

⚑ Render Optimization Strategies

Now let's dive into specific techniques for optimizing React component renders. These are the practical tools you'll use every day to keep your applications fast.

πŸ€– First: does the React Compiler already handle this?

The next few strategies β€” React.memo, useMemo, useCallback β€” are manual memoization. In a React 19 project with the React Compiler turned on, the compiler applies equivalent memoization automatically, so a lot of this code becomes optional. The order of operations in 2026 is:

  1. Write clear components that follow the Rules of React.
  2. Enable the React Compiler and let it memoize for you.
  3. Profile. If something is still slow, reach for manual memoization at that spot.

Everything below is still essential knowledge: it's the mental model behind what the compiler does, it's what you use when the compiler can't (opt-outs, libraries, gradual migration), and it's how you interpret a profile. So learn the tools β€” just don't sprinkle them everywhere out of habit.

πŸ’‘ Enabling the React Compiler

The compiler ships as a Babel plugin and a matching lint rule. In a Vite project it's roughly:

npm install --save-dev babel-plugin-react-compiler eslint-plugin-react-hooks
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['babel-plugin-react-compiler', {}]],
      },
    }),
  ],
});

The companion eslint-plugin-react-hooks (v5+) flags components that break the Rules of React so the compiler can safely optimize them.

🎯 Strategy #1: React.memo

React.memo is a higher-order component that prevents unnecessary re-renders by memoizing the component output:

Let's make the re-render cascade concrete. Imagine an <App> that renders three children β€” a <Counter> that receives the changing count, and a <Header> and <Footer> whose props never change. Update the parent's state a few times, then flip React.memo on for Header and Footer and watch the wasted renders disappear.

<App count> β†’ <Counter count/> <Header title/> <Footer year/>

πŸ‘† Update the parent a few times, then toggle React.memo and compare.

πŸ’‘ Reading the demo

<Counter> re-renders every time because its count prop genuinely changes β€” that's correct work. <Header> and <Footer> get identical props every time, so with React.memo they're skipped and the "wasted" counter stops climbing. In a React Compiler project this skip usually happens for you; the demo shows the mechanism the compiler is automating.

// Without React.memo
function ExpensiveComponent({ data }: { data: ComplexData }) {
  console.log('ExpensiveComponent rendering');
  
  // Imagine this has expensive rendering logic
  return (
    <div>
      <h3>{data.title}</h3>
      <ComplexVisualization data={data} />
    </div>
  );
}

// With React.memo - only re-renders if props change
const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);

// Usage
function ParentComponent() {
  const [count, setCount] = useState(0);
  const complexData = { title: 'Chart', values: [1, 2, 3] };
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
      
      {/* This won't re-render when count changes */}
      <MemoizedExpensiveComponent data={complexData} />
    </div>
  );
}

⚠️ React.memo Gotcha: Reference Equality

React.memo uses shallow comparison. If you pass objects or functions as props, they need to be memoized too, or React.memo won't help:

// 😱 Still re-renders because data is a new object each time
function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <MemoizedChild data={{ value: 123 }} /> // New object every render!
  );
}

// βœ… Fixed with useMemo
function Parent() {
  const [count, setCount] = useState(0);
  const data = useMemo(() => ({ value: 123 }), []);
  
  return <MemoizedChild data={data} />;
}

🎯 Strategy #2: useMemo for Expensive Calculations

We've seen useMemo briefly. Let's explore it in depth:

// Example: Filtering and sorting a large dataset
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  rating: number;
}

function ProductFilter({ products }: { products: Product[] }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState<'name' | 'price' | 'rating'>('name');
  
  // 😱 Without useMemo - runs on every render (even when typing!)
  const filteredAndSorted = products
    .filter(p => 
      (category === 'all' || p.category === category) &&
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    )
    .sort((a, b) => {
      if (sortBy === 'name') return a.name.localeCompare(b.name);
      if (sortBy === 'price') return a.price - b.price;
      return b.rating - a.rating;
    });
  
  // βœ… With useMemo - only recalculates when dependencies change
  const filteredAndSortedMemo = useMemo(() => {
    console.log('Filtering and sorting...');
    
    return products
      .filter(p => 
        (category === 'all' || p.category === category) &&
        p.name.toLowerCase().includes(searchTerm.toLowerCase())
      )
      .sort((a, b) => {
        if (sortBy === 'name') return a.name.localeCompare(b.name);
        if (sortBy === 'price') return a.price - b.price;
        return b.rating - a.rating;
      });
  }, [products, category, searchTerm, sortBy]);
  
  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
      </select>
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
        <option value="name">Name</option>
        <option value="price">Price</option>
        <option value="rating">Rating</option>
      </select>
      
      <ProductList products={filteredAndSortedMemo} />
    </div>
  );
}

βœ… useMemo Best Practices

  • Do use for expensive computations (array operations on large data, complex math)
  • Do use when the result is passed to child components
  • Don't use for simple calculations (it adds overhead)
  • Don't use as a semantic guarantee (React may still recalculate)
  • Always include all dependencies in the dependency array

🎯 Strategy #3: useCallback for Function Memoization

useCallback is like useMemo for functions:

// Example: Passing callbacks to memoized children
interface TodoItemProps {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}

const TodoItem = React.memo(({ todo, onToggle, onDelete }: TodoItemProps) => {
  console.log('TodoItem rendering:', todo.text);
  
  return (
    <div>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </div>
  );
});

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  
  // 😱 Without useCallback - new functions every render
  const handleToggle = (id: number) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const handleDelete = (id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };
  
  // βœ… With useCallback - functions stay the same
  const handleToggleMemo = useCallback((id: number) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []); // No dependencies needed - uses functional update
  
  const handleDeleteMemo = useCallback((id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);
  
  return (
    <div>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggleMemo}
          onDelete={handleDeleteMemo}
        />
      ))}
    </div>
  );
}

Key insight: useCallback is only useful when passing functions to memoized child components. If the child isn't memoized with React.memo, useCallback provides no benefit.

🎯 Strategy #4: Component Lazy Loading

Not all components need to be loaded immediately. Use React.lazy and Suspense to split code and load components on demand:

// Without lazy loading - entire app loads at once
import { HeavyChart } from './components/HeavyChart';
import { ComplexEditor } from './components/ComplexEditor';
import { VideoPlayer } from './components/VideoPlayer';

function Dashboard() {
  const [activeTab, setActiveTab] = useState('overview');
  
  return (
    <div>
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tab value="overview">Overview</Tab>
        <Tab value="charts">Charts</Tab>
        <Tab value="editor">Editor</Tab>
      </Tabs>
      
      {activeTab === 'overview' && <Overview />}
      {activeTab === 'charts' && <HeavyChart />}
      {activeTab === 'editor' && <ComplexEditor />}
    </div>
  );
}

// βœ… With lazy loading - components load only when needed
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const ComplexEditor = lazy(() => import('./components/ComplexEditor'));
const VideoPlayer = lazy(() => import('./components/VideoPlayer'));

function Dashboard() {
  const [activeTab, setActiveTab] = useState('overview');
  
  return (
    <div>
      <Tabs value={activeTab} onChange={setActiveTab}>
        <Tab value="overview">Overview</Tab>
        <Tab value="charts">Charts</Tab>
        <Tab value="editor">Editor</Tab>
      </Tabs>
      
      <Suspense fallback={<LoadingSpinner />}>
        {activeTab === 'overview' && <Overview />}
        {activeTab === 'charts' && <HeavyChart />}
        {activeTab === 'editor' && <ComplexEditor />}
      </Suspense>
    </div>
  );
}

πŸ’‘ When to Use Lazy Loading

  • Route-based splits - Different routes can load their own code
  • Modal dialogs - Only load when the modal opens
  • Heavy components - Charts, editors, video players
  • Below-the-fold content - Content users might not scroll to
  • Admin features - Code that only some users need

🎯 Strategy #5: Windowing Large Lists

For long lists, render only what's visible on screen using virtualization:

// Installing react-window
// npm install react-window @types/react-window

import { FixedSizeList } from 'react-window';

interface Message {
  id: number;
  author: string;
  text: string;
  timestamp: Date;
}

// 😱 Without windowing - renders all 10,000 messages
function ChatHistory({ messages }: { messages: Message[] }) {
  return (
    <div className="chat-history">
      {messages.map(message => (
        <MessageItem key={message.id} message={message} />
      ))}
    </div>
  );
}

// βœ… With windowing - only renders visible messages
function VirtualizedChatHistory({ messages }: { messages: Message[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
    const message = messages[index];
    
    return (
      <div style={style}>
        <MessageItem message={message} />
      </div>
    );
  };
  
  return (
    <FixedSizeList
      height={600}           // Visible height in pixels
      itemCount={messages.length}
      itemSize={80}          // Height of each message
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// For variable height items, use VariableSizeList
import { VariableSizeList } from 'react-window';

function VariableHeightList({ items }: { items: Item[] }) {
  const listRef = useRef<VariableSizeList>(null);
  const rowHeights = useRef<{ [key: number]: number }>({});
  
  const getItemSize = (index: number) => {
    return rowHeights.current[index] || 50; // Default height
  };
  
  const setRowHeight = (index: number, size: number) => {
    listRef.current?.resetAfterIndex(0);
    rowHeights.current[index] = size;
  };
  
  const Row = ({ index, style }: any) => {
    const rowRef = useRef<HTMLDivElement>(null);
    
    useEffect(() => {
      if (rowRef.current) {
        setRowHeight(index, rowRef.current.clientHeight);
      }
    }, [index]);
    
    return (
      <div style={style}>
        <div ref={rowRef}>
          <VariableHeightItem item={items[index]} />
        </div>
      </div>
    );
  };
  
  return (
    <VariableSizeList
      ref={listRef}
      height={600}
      itemCount={items.length}
      itemSize={getItemSize}
      width="100%"
    >
      {Row}
    </VariableSizeList>
  );
}

βœ… Windowing Performance Impact

Windowing can provide dramatic improvements:

  • 1,000 items: Renders ~20 instead of 1,000 (50x faster)
  • 10,000 items: Renders ~20 instead of 10,000 (500x faster)
  • 100,000 items: Renders ~20 instead of 100,000 (5000x faster)

The performance is constant regardless of list size!

The react-window code above is still perfectly good, but for new projects the community default is now TanStack Virtual. It's headless β€” it hands you positions and sizes, and you render the markup β€” which means it works for lists, grids, tables, and variable-height rows with one small API:

// npm install @tanstack/react-virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualMessages({ messages }: { messages: Message[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,   // rough row height; measured rows can vary
    overscan: 5,              // render a few extra rows above/below
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      {/* One tall spacer element sets the total scrollable height */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: row.size,
              transform: `translateY(${row.start}px)`,
            }}
          >
            <MessageItem message={messages[row.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

🎯 Strategy #6: Debouncing and Throttling

Limit how often expensive operations run in response to user input:

// Debounce - wait until user stops typing
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

// Using debounce for search
function SearchBar() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
  
  // This only runs when user stops typing for 300ms
  useEffect(() => {
    if (debouncedSearchTerm) {
      fetchSearchResults(debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);
  
  return (
    <input
      type="search"
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search..."
    />
  );
}

// Throttle - limit how often a function can run
function useThrottle<T>(value: T, limit: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastRan = useRef(Date.now());
  
  useEffect(() => {
    const handler = setTimeout(() => {
      if (Date.now() - lastRan.current >= limit) {
        setThrottledValue(value);
        lastRan.current = Date.now();
      }
    }, limit - (Date.now() - lastRan.current));
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, limit]);
  
  return throttledValue;
}

// Using throttle for scroll events
function ScrollTracker() {
  const [scrollPosition, setScrollPosition] = useState(0);
  const throttledPosition = useThrottle(scrollPosition, 100);
  
  useEffect(() => {
    const handleScroll = () => {
      setScrollPosition(window.scrollY);
    };
    
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  
  // This only updates every 100ms instead of every frame
  useEffect(() => {
    updateScrollIndicator(throttledPosition);
  }, [throttledPosition]);
  
  return <div>Scroll position: {throttledPosition}</div>;
}
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

🎯 Strategy #7: Concurrent Rendering with useTransition & useDeferredValue

Debouncing hides an expensive render by delaying it. React has a better, built-in answer: keep the urgent update (the keystroke) instant, and let the expensive update (re-filtering 10,000 rows) happen in the background without blocking the input. That's what the concurrent hooks useTransition and useDeferredValue do β€” and they're a big part of keeping INP low.

useTransition gives you a startTransition function to wrap non-urgent state updates, plus an isPending flag so you can show a subtle "updating…" hint:

import { useState, useTransition, useMemo } from 'react';

function ProductSearch({ products }: { products: Product[] }) {
  const [text, setText] = useState('');       // urgent: what's in the box
  const [query, setQuery] = useState('');      // non-urgent: what we filter by
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setText(e.target.value);                   // update the input immediately
    startTransition(() => {
      setQuery(e.target.value);                // filtering can lag behind β€” that's fine
    });
  }

  const results = useMemo(
    () => products.filter(p => p.name.toLowerCase().includes(query.toLowerCase())),
    [products, query]
  );

  return (
    <div>
      <input value={text} onChange={handleChange} placeholder="Search..." />
      {isPending && <span> updating…</span>}
      <SearchResults data={results} />
    </div>
  );
}

useDeferredValue is the even simpler cousin when you don't own the state update β€” you just defer a value. React renders once with the old value (keeping typing snappy), then re-renders with the new value when it has spare time:

import { useState, useDeferredValue, useMemo } from 'react';

function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);   // lags behind `query`

  const results = useMemo(
    () => products.filter(p => p.name.toLowerCase().includes(deferredQuery.toLowerCase())),
    [products, deferredQuery]
  );

  const isStale = query !== deferredQuery;

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <SearchResults data={results} />
      </div>
    </div>
  );
}

βœ… Transitions vs debouncing β€” which and when?

  • Debounce when the goal is to fire fewer side effects β€” e.g. don't hit the network on every keystroke. Debounce the fetch.
  • useTransition / useDeferredValue when the goal is to keep the UI responsive during an expensive render β€” the work still happens, it just yields to urgent updates so the input never stutters.
  • They compose: debounce the API call and defer the render of the results.
graph LR A["User types a key"] --> B["Urgent: input value updates instantly"] A --> C["Transition: expensive filter/render"] C --> D["Runs in background, can be interrupted"] D --> E["Results paint when the main thread is free"] B --> F["Input never stutters (low INP)"] style B fill:#c8e6c9 style F fill:#c8e6c9 style C fill:#fff9c4 style D fill:#fff9c4

πŸ“¦ 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 Interaction to Next Paint (INP)

INP measures responsiveness across every interaction. Target: under 200 milliseconds (this is the metric that replaced FID in 2024).

Poor INP is caused by long JavaScript tasks blocking the main thread β€” including heavy re-renders. When the browser is busy running your code, it can't respond to clicks, taps, or keystrokes, so the next paint is delayed. The concurrent hooks from Strategy #7 (useTransition, useDeferredValue) are one of your best INP tools because they let expensive renders yield to urgent input.

πŸ”§ INP Optimization Strategies

// 1. Break up long tasks
// ❌ Bad: Long synchronous task blocks thread
function processLargeDataset(data: Item[]) {
  const results = data.map(item => {
    // Complex processing...
    return expensiveCalculation(item);
  });
  
  displayResults(results);
}

// βœ… Good: Break into chunks with requestIdleCallback
async function processLargeDataset(data: Item[]) {
  const results: ProcessedItem[] = [];
  const chunkSize = 100;
  
  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);
    
    // Process chunk
    const processedChunk = chunk.map(item => expensiveCalculation(item));
    results.push(...processedChunk);
    
    // Yield to browser
    await new Promise(resolve => {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(resolve);
      } else {
        setTimeout(resolve, 0);
      }
    });
  }
  
  displayResults(results);
}

// 2. Use Web Workers for heavy computations
// worker.ts
self.addEventListener('message', (e) => {
  const result = expensiveCalculation(e.data);
  self.postMessage(result);
});

// Component.tsx
function DataProcessor() {
  const [result, setResult] = useState<Result | null>(null);
  
  useEffect(() => {
    const worker = new Worker(new URL('./worker.ts', import.meta.url));
    
    worker.postMessage(largeDataset);
    
    worker.onmessage = (e) => {
      setResult(e.data);
      worker.terminate();
    };
    
    return () => worker.terminate();
  }, []);
  
  return <ResultDisplay data={result} />;
}

// 3. Defer non-critical JavaScript
function App() {
  useEffect(() => {
    // Load analytics after page is interactive
    if ('requestIdleCallback' in window) {
      requestIdleCallback(() => {
        import('./analytics').then(({ initAnalytics }) => {
          initAnalytics();
        });
      });
    }
  }, []);
  
  return <YourApp />;
}

βœ… INP Quick Wins

  1. Wrap expensive state updates in useTransition / useDeferredValue
  2. Code split to reduce JavaScript execution time
  3. Break up long tasks into smaller chunks (yield to the browser)
  4. Use Web Workers for CPU-intensive work
  5. Defer third-party scripts (analytics, chat widgets)
  6. Remove unused JavaScript and use modern, smaller libraries

🎯 Improving Cumulative Layout Shift (CLS)

CLS measures visual stability. Target: < 0.1

Layout shifts happen when visible page content moves unexpectedly. This is incredibly frustrating for users!

πŸ”§ CLS Optimization Strategies

// 1. Always include size attributes for images
// ❌ Bad: Image loads and pushes content down
<img src="/photo.jpg" alt="Photo" />

// βœ… Good: Space reserved, no layout shift
<img src="/photo.jpg" alt="Photo" width={800} height={600} />

// βœ… Also good: aspect-ratio
<img 
  src="/photo.jpg" 
  alt="Photo"
  style={{ width: '100%', aspectRatio: '16/9' }}
/>

// 2. Reserve space for dynamic content
// ❌ Bad: Ad or widget loads and shifts content
function Sidebar() {
  const [ad, setAd] = useState<Ad | null>(null);
  
  useEffect(() => {
    loadAd().then(setAd);
  }, []);
  
  return (
    <aside>
      {ad && <AdWidget ad={ad} />} {/* Suddenly appears! */}
      <SidebarContent />
    </aside>
  );
}

// βœ… Good: Reserve space with skeleton
function Sidebar() {
  const [ad, setAd] = useState<Ad | null>(null);
  
  useEffect(() => {
    loadAd().then(setAd);
  }, []);
  
  return (
    <aside>
      <div style={{ minHeight: '250px' }}>
        {ad ? (
          <AdWidget ad={ad} />
        ) : (
          <AdSkeleton /> {/* Placeholder with same size */}
        )}
      </div>
      <SidebarContent />
    </aside>
  );
}

// 3. Avoid inserting content above existing content
// ❌ Bad: New message appears above and shifts everything down
function ChatMessages({ messages }: { messages: Message[] }) {
  return (
    <div>
      {messages.map(msg => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

// βœ… Good: Scroll to keep position stable
function ChatMessages({ messages }: { messages: Message[] }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const prevHeightRef = useRef(0);
  
  useLayoutEffect(() => {
    if (containerRef.current) {
      const newHeight = containerRef.current.scrollHeight;
      const heightDiff = newHeight - prevHeightRef.current;
      
      // Adjust scroll to maintain position
      if (heightDiff > 0) {
        containerRef.current.scrollTop += heightDiff;
      }
      
      prevHeightRef.current = newHeight;
    }
  }, [messages]);
  
  return (
    <div ref={containerRef}>
      {messages.map(msg => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

// 4. Use font-display for web fonts
// In your CSS
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap; /* Show fallback font immediately */
}

// Or preload fonts
<link
  rel="preload"
  as="font"
  type="font/woff2"
  href="/fonts/custom.woff2"
  crossOrigin="anonymous"
/>

βœ… CLS Quick Wins

  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

// web-vitals v4+ exports onINP β€” onFID was removed when FID was retired.
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';

// Send metrics to analytics
function sendToAnalytics(metric: Metric) {
  // Send to your analytics service
  const body = JSON.stringify(metric);

  // Use `navigator.sendBeacon()` if available, falling back to `fetch()`
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/analytics', body);
  } else {
    fetch('/analytics', { body, method: 'POST', keepalive: true });
  }
}

// Track all Core Web Vitals
export function initWebVitals() {
  onCLS(sendToAnalytics);
  onINP(sendToAnalytics);   // replaces onFID
  onLCP(sendToAnalytics);
  onFCP(sendToAnalytics);
  onTTFB(sendToAnalytics);
}

// Usage in your app
function App() {
  useEffect(() => {
    // Vite exposes the mode via import.meta.env β€” no process.env needed
    if (import.meta.env.PROD) {
      initWebVitals();
    }
  }, []);

  return <YourApp />;
}

// Advanced: Track by route
import { useLocation } from 'react-router-dom';

function WebVitalsReporter() {
  const location = useLocation();

  useEffect(() => {
    const sendMetric = (metric: Metric) => {
      sendToAnalytics({
        ...metric,
        route: location.pathname,
      } as Metric);
    };

    onCLS(sendMetric);
    onINP(sendMetric);
    onLCP(sendMetric);
  }, [location]);

  return null;
}

πŸ’‘ Real User Monitoring (RUM) Services

  • Google Analytics 4 - Built-in Web Vitals reporting
  • Vercel Analytics - Automatic for Vercel deployments
  • Sentry Performance - Detailed performance monitoring
  • New Relic - Enterprise monitoring solution
  • Datadog RUM - Comprehensive monitoring

πŸ‹οΈ Practice Exercises

Exercise 1: Profile and Optimize a Slow Component

Objective: Use React DevTools Profiler to find and fix performance issues

Task: Create a product listing page that renders slowly, profile it, then optimize it.

πŸ’‘ Hints
  • Create a ProductList component with 500+ products
  • Add filters and sorting that cause re-renders
  • Use React DevTools Profiler to record interactions
  • Look for components rendering unnecessarily
  • Apply React.memo, useMemo, and useCallback
  • Consider virtualization for the long list
βœ… Solution Approach
// Start with slow version
function ProductList({ products }: Props) {
  const [filter, setFilter] = useState('');
  const [sort, setSort] = useState('name');
  
  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(filter.toLowerCase())
  );
  
  const sorted = [...filtered].sort((a, b) => {
    if (sort === 'name') return a.name.localeCompare(b.name);
    return a.price - b.price;
  });
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {sorted.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// Optimize with memoization and virtualization
const ProductCard = React.memo(({ product }: Props) => {
  return <div>{product.name} - ${product.price}</div>;
});

function ProductList({ products }: Props) {
  const [filter, setFilter] = useState('');
  const [sort, setSort] = useState('name');
  
  const filtered = useMemo(() => 
    products.filter(p => 
      p.name.toLowerCase().includes(filter.toLowerCase())
    ),
    [products, filter]
  );
  
  const sorted = useMemo(() => 
    [...filtered].sort((a, b) => {
      if (sort === 'name') return a.name.localeCompare(b.name);
      return a.price - b.price;
    }),
    [filtered, sort]
  );
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <FixedSizeList
        height={600}
        itemCount={sorted.length}
        itemSize={80}
        width="100%"
      >
        {({ index, style }) => (
          <div style={style}>
            <ProductCard product={sorted[index]} />
          </div>
        )}
      </FixedSizeList>
    </div>
  );
}

Exercise 2: Implement Route-Based Code Splitting

Objective: Split a multi-route application into separate bundles

Task: Take an application with 5+ routes and implement lazy loading for each route.

πŸ’‘ Hints
  • Use React.lazy() and Suspense
  • Create a loading component for the Suspense fallback
  • Check bundle sizes before and after
  • Implement prefetching on navigation hover
  • Test that routes load correctly
βœ… Solution Approach
// Before: All routes load upfront
import Home from './pages/Home';
import About from './pages/About';
import Products from './pages/Products';
import Contact from './pages/Contact';
import Admin from './pages/Admin';

// After: Lazy load routes
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Products = lazy(() => import('./pages/Products'));
const Contact = lazy(() => import('./pages/Contact'));
const Admin = lazy(() => import('./pages/Admin'));

// Loading component
function LoadingScreen() {
  return (
    <div style={{ padding: '2rem', textAlign: 'center' }}>
      <div className="spinner" />
      <p>Loading...</p>
    </div>
  );
}

// App with lazy routes
function App() {
  return (
    <BrowserRouter>
      <Navigation />
      <Suspense fallback={<LoadingScreen />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/products" element={<Products />} />
          <Route path="/contact" element={<Contact />} />
          <Route path="/admin" element={<Admin />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

// Add prefetching
function Navigation() {
  const preloadAbout = () => import('./pages/About');
  const preloadProducts = () => import('./pages/Products');
  
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link 
        to="/about" 
        onMouseEnter={preloadAbout}
        onFocus={preloadAbout}
      >
        About
      </Link>
      <Link 
        to="/products"
        onMouseEnter={preloadProducts}
        onFocus={preloadProducts}
      >
        Products
      </Link>
    </nav>
  );
}

Exercise 3: Optimize Images for Performance

Objective: Implement responsive, lazy-loaded images with modern formats

Task: Create an image gallery with proper optimization techniques.

πŸ’‘ Hints
  • Use multiple image sizes (small, medium, large)
  • Implement lazy loading (native or Intersection Observer)
  • Add proper width and height attributes
  • Use WebP with fallbacks
  • Measure CLS score before and after
βœ… Solution Approach
interface GalleryImage {
  id: string;
  alt: string;
  small: string;
  medium: string;
  large: string;
  width: number;
  height: number;
}

function Gallery({ images }: { images: GalleryImage[] }) {
  return (
    <div className="gallery">
      {images.map((image, index) => (
        <picture key={image.id}>
          <source
            type="image/webp"
            srcSet={`
              ${image.small} 480w,
              ${image.medium} 800w,
              ${image.large} 1200w
            `}
            sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
          />
          
          <img
            src={image.medium}
            srcSet={`
              ${image.small} 480w,
              ${image.medium} 800w,
              ${image.large} 1200w
            `}
            sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
            alt={image.alt}
            width={image.width}
            height={image.height}
            loading={index < 6 ? 'eager' : 'lazy'}
            style={{
              aspectRatio: `${image.width}/${image.height}`,
              width: '100%',
              height: 'auto',
            }}
          />
        </picture>
      ))}
    </div>
  );
}

Exercise 4: Improve Core Web Vitals

Objective: Take a slow app and improve all three Core Web Vitals metrics

Task: Run Lighthouse on an app, identify issues, and fix them to achieve good scores.

πŸ’‘ Hints
  • Run Lighthouse in Chrome DevTools
  • Focus on the three Core Web Vitals
  • Implement recommendations from Lighthouse report
  • Measure before and after scores
  • Test on real device with network throttling
βœ… Solution Checklist
  • For LCP:
    • Preload hero image
    • Use WebP format
    • Implement CDN
    • Remove render-blocking resources
  • For INP:
    • Wrap expensive updates in useTransition / useDeferredValue
    • Code split routes and defer third-party scripts
    • Break up long tasks so the main thread can respond
    • Reduce JavaScript execution time
  • For CLS:
    • Add dimensions to all images
    • Reserve space for ads/embeds
    • Use font-display: swap
    • Avoid inserting content above viewport

πŸ“ Knowledge Check

Question 1: What does React.memo do?

Show Answer

Answer: React.memo is a higher-order component that memoizes a component's output. It prevents re-renders when props haven't changed by doing a shallow comparison of props. This is useful for optimizing expensive components that receive the same props frequently.

Question 2: What's the difference between useMemo and useCallback?

Show Answer

Answer:

  • useMemo: Memoizes the result of a computation. Returns a cached value.
  • useCallback: Memoizes a function itself. Returns a cached function reference.

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)

Question 3: What are the three Core Web Vitals?

Show Answer

Answer:

  1. LCP (Largest Contentful Paint): Loading performance β€” should be under 2.5s
  2. INP (Interaction to Next Paint): Responsiveness β€” should be under 200ms. INP replaced FID (First Input Delay) as a Core Web Vital in March 2024.
  3. CLS (Cumulative Layout Shift): Visual stability β€” should be under 0.1

Question 4: When should you use virtualization/windowing?

Show Answer

Answer: Use virtualization when rendering long lists (typically 100+ items) where only a portion is visible at once. Virtualization only renders items in the viewport, dramatically improving performance for lists with hundreds or thousands of items. Libraries like react-window or @tanstack/react-virtual make this easy.

Question 5: What's the difference between debouncing and throttling?

Show Answer

Answer:

  • Debouncing: Waits for a pause in events before executing. Fires after user stops triggering events. Good for search inputs.
  • Throttling: Executes at most once per time interval regardless of how many times it's triggered. Good for scroll/resize handlers.

Question 6: Why is code splitting important?

Show Answer

Answer: Code splitting breaks your JavaScript bundle into smaller chunks that load on demand. This reduces initial bundle size, leading to faster initial page loads and improved Time to Interactive. Users only download the code they need, when they need it. Route-based splitting is the most effective strategy.

Question 7: What attributes should every image have to prevent layout shift?

Show Answer

Answer: Every image should have:

  • 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 the React DevTools Profiler, Lighthouse, and the web-vitals library to find real issues
  • Let the compiler help: The React Compiler auto-memoizes; reach for manual React.memo, useMemo, and useCallback only where the profiler points you
  • Stay responsive: useTransition and useDeferredValue keep the UI snappy during expensive renders
  • Split Code: Lazy load routes and heavy components; virtualize long lists with TanStack Virtual or react-window
  • Optimize Images: Use modern formats, responsive images, and lazy loading
  • Improve Web Vitals: Focus on LCP, INP, and CLS for a better user experience
  • Monitor Production: Track real user metrics to catch regressions
mindmap root(("Performance Optimization")) Measurement React DevTools Profiler Lighthouse web-vitals library Bundle Analyzer Rendering React Compiler React.memo useMemo and useCallback useTransition and useDeferredValue 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