Skip to content

React Performance Optimization — Profiling, Memoization, and Bundling

DodaTech 7 min read

In this tutorial, you'll learn about React Performance Optimization. We cover key concepts, practical examples, and best practices.

What You'll Learn

Optimize React application performance using profiling, memoization (React.memo, useMemo, useCallback), code splitting with React.lazy, windowing for large lists, and bundle analysis with practical metrics.

Why It Matters

Slow React apps lose users. Every additional 100ms of load time reduces conversion by 7%. Unnecessary re-renders, large bundles, and unoptimized lists cause janky interactions and poor Core Web Vitals scores that hurt both user experience and SEO.

Real-World Use

A data dashboard rendering 10,000 rows of financial data uses windowing to render only visible rows, memoization to prevent chart re-computation on unrelated state changes, and code splitting to load the analytics module only when the user navigates to it.

Learning Path

flowchart LR
    A[React Basics] --> B[Hooks Deep Dive]
    B --> C[Performance Optimization]
    C --> D[Code Splitting]
    D --> E[Design Patterns]
    E --> F[Final Project]
    C -->|You are here| C

Profiling with React DevTools

Before optimizing, measure. React DevTools Profiler records render times and identifies which components re-render and why.

import { Profiler, ProfilerOnRenderCallback } from "react";

function onRenderCallback: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime,
) => {
  console.table({
    id,
    phase,             // "mount" or "update"
    actualDuration,    // time spent rendering this component
    baseDuration,      // estimated time without memoization
    commitTime,        // when the commit happened
  });
};

function Dashboard() {
  return (
    <Profiler id="Dashboard" onRender={onRenderCallback}>
      <ExpensiveWidget />
    </Profiler>
  );
}

Expected output: After each render, the browser console shows a table with the Profiler data. Use this to identify components taking more than 5ms to render — those are candidates for memoization.

React.memo — Preventing Unnecessary Re-renders

React.memo wraps a component to skip re-rendering if its props have not changed (shallow comparison).

import { memo, useState } from "react";

interface StockRowProps {
  symbol: string;
  price: number;
  change: number;
}

const StockRow = memo(function StockRow({ symbol, price, change }: StockRowProps) {
  console.log(`Rendering ${symbol}`);
  return (
    <tr>
      <td>{symbol}</td>
      <td>${price.toFixed(2)}</td>
      <td style={{ color: change >= 0 ? "green" : "red" }}>
        {change >= 0 ? "+" : ""}{change.toFixed(2)}%
      </td>
    </tr>
  );
});

function StockTable() {
  const [filter, setFilter] = useState("");
  const stocks = [
    { symbol: "AAPL", price: 178.50, change: 1.2 },
    { symbol: "GOOGL", price: 141.80, change: -0.5 },
    { symbol: "MSFT", price: 378.90, change: 2.1 },
  ];

  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter..." />
      <table>
        <tbody>
          {stocks.map((s) => (
            <StockRow key={s.symbol} {...s} />
          ))}
        </tbody>
      </table>
    </div>
  );
}

Expected output: console.log runs once per stock on initial mount. Typing in the filter input does NOT trigger re-renders of StockRow because the stocks array reference has not changed. Without memo, every keystroke would re-render all three rows.

useMemo and useCallback — Expensive Computations and Stable Callbacks

import { useMemo, useCallback, useState, memo } from "react";

interface DataPoint {
  timestamp: number;
  value: number;
}

function MovingAverage({ data, windowSize }: { data: DataPoint[]; windowSize: number }) {
  const [color, setColor] = useState("#007bff");

  // Only recompute when data or windowSize changes
  const averages = useMemo(() => {
    console.log("Computing moving averages...");
    const result: number[] = [];
    for (let i = 0; i < data.length; i++) {
      const start = Math.max(0, i - windowSize + 1);
      const slice = data.slice(start, i + 1);
      const avg = slice.reduce((s, p) => s + p.value, 0) / slice.length;
      result.push(avg);
    }
    return result;
  }, [data, windowSize]);

  // Stable callback — same reference across renders
  const handleColorChange = useCallback((newColor: string) => {
    setColor(newColor);
  }, []);

  return (
    <div>
      <ColorPicker onChange={handleColorChange} />
      <Chart data={averages} color={color} />
    </div>
  );
}

Expected output: "Computing moving averages..." appears only when data or windowSize actually change. Changing the color does NOT trigger re-computation. The handleColorChange reference stays stable, preventing ColorPicker from re-rendering unless its other props change.

Code Splitting with React.lazy

Lazy load routes and heavy components so the initial bundle stays small.

import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Analytics = lazy(() => import("./pages/Analytics"));
const Settings = lazy(() => import("./pages/Settings"));

function LoadingFallback() {
  return <div aria-label="Loading page">Loading...</div>;
}

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingFallback />}>
        <Routes>
          <Route path="/" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Expected output: The initial bundle contains only the shell and Dashboard code. When the user navigates to /analytics, the Analytics chunk loads on demand. The LoadingFallback renders while the chunk downloads. Network tab shows separate JS chunks for each route.

Windowing with react-window

Rendering thousands of list items at once causes layout thrashing and jank. Windowing renders only the visible items.

import { FixedSizeList as List } from "react-window";

interface LogEntry {
  id: number;
  level: "info" | "warn" | "error";
  message: string;
  timestamp: string;
}

const logs: LogEntry[] = Array.from({ length: 100000 }, (_, i) => ({
  id: i,
  level: i % 10 === 0 ? "error" : i % 5 === 0 ? "warn" : "info",
  message: `Log entry number ${i + 1}`,
  timestamp: new Date().toISOString(),
}));

function LogRow({ index, style }: { index: number; style: React.CSSProperties }) {
  const log = logs[index];
  return (
    <div style={style}>
      <span
        style={{
          color: log.level === "error" ? "red" : log.level === "warn" ? "orange" : "green",
          marginRight: "8px",
        }}
      >
        [{log.level.toUpperCase()}]
      </span>
      {log.timestamp} — {log.message}
    </div>
  );
}

function LogViewer() {
  return (
    <List
      height={600}
      itemCount={logs.length}
      itemSize={35}
      width="100%"
    >
      {LogRow}
    </List>
  );
}

Expected output: Only ~18 rows are rendered in the DOM at any time, even though the data has 100,000 entries. Scrolling recycles the same DOM nodes with updated content. The page stays at 60fps during rapid scrolling.

Bundle Analysis

Run these commands to analyze your bundle composition:

# Using Vite
npx vite-bundle-visualizer

# Using Create React App
npx source-map-explorer build/static/js/*.js

Expected output: A treemap visualization showing which modules consume the most bytes. Common targets: moment.js (large locale data), lodash (tree-shake!). Replace with date-fns or native Intl where possible.

Common Performance Mistakes

1. Premature Memoization

Wrapping every function in useCallback and every value in useMemo adds overhead. Profile first, then optimize the top 3 bottlenecks.

2. Inline Functions in Memoized Components

Passing an inline arrow function as a prop to a memoized child breaks memoization because a new function reference is created every render. Use useCallback.

3. Not Using Production Builds

React development builds include warnings, propType checks, and slow development mode. Always test performance against a production build.

4. Large Bundles from Unused Imports

Importing only what you need from libraries and enabling tree-shaking in your bundler reduces bundle size.

Practice Questions

When does React.memo actually help performance?

React.memo helps when: (1) the component renders often, (2) it receives the same props frequently, (3) the render is expensive enough that the shallow comparison cost is less than the render cost. Profile to confirm.

What is the difference between code splitting and lazy loading?

Code splitting is the technique of splitting the bundle into separate chunks. Lazy loading is the strategy of loading those chunks on demand. React.lazy enables both — it code-splits at the component boundary and lazily loads the chunk when the component renders.

Should I use virtualization for every list?

No. Virtualization adds complexity and works best when: (1) the list has more than 1,000 items, (2) items have consistent height, and (3) the list scrolls. For short lists (under 100 items), regular rendering is simpler and fast enough.

Challenge

Build a product listing page that renders 5,000 products with images, titles, prices, and ratings. Profile it with React DevTools. Apply three optimizations: React.memo for product cards, wind owing for the list, and lazy loading for images. Measure the render time before and after.

Real-World Task

Analyze a production React app with the Chrome Performance tab and React DevTools Profiler. Identify the top three components causing re-render bottlenecks. Apply memoization, extract expensive computations with useMemo, and split the largest route with React.lazy. Document the performance improvement with before/after metrics.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — performance-critical applications serving millions of users with sub-second interactions.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro