Reactadvanced

React Performance Optimization

Master React performance: memoization, code splitting, lazy loading, bundle analysis, profiling, and Core Web Vitals optimization with practical examples.

15 min readยทPublished Mar 21, 2026
reactperformanceoptimizationmemoization

The Performance Mindset

Most React apps are fast enough out of the box. React's virtual DOM diffing is efficient, and modern browsers are powerful. The danger is premature optimization โ€” adding complexity before measuring.

The performance workflow:

Measure  -->  Identify Bottleneck  -->  Optimize  -->  Measure Again
   ^                                                        |
   +--------------------------------------------------------+

Never skip the first step. Profiling before optimizing tells you what matters. Profiling after optimizing tells you if it worked.

React.memo โ€” Component Memoization

React.memo is a higher-order component that skips re-rendering when props have not changed. By default, it uses shallow comparison on all props.

import { memo } from 'react';

const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

When React.memo Helps

Parent re-renders (state change)
     |
     +---> Child A (props changed)     --> RE-RENDERS (correct)
     |
     +---> Child B (props unchanged)   --> SKIPPED by memo (saved work)
     |
     +---> Child C (no memo, props unchanged) --> RE-RENDERS (wasted)

When React.memo Hurts

If a component receives new object or function references every render, memo's shallow comparison fails every time โ€” you pay the comparison cost plus the re-render.

// BAD: memo is useless here because `style` is a new object every render
function Parent() {
  return <MemoizedChild style={{ color: 'red' }} />;
}

// FIX: hoist the constant or use useMemo
const style = { color: 'red' };
function Parent() {
  return <MemoizedChild style={style} />;
}

Custom Comparison Function

const UserCard = memo(function UserCard({ user, theme }) {
  return (
    <div className={theme}>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}, (prevProps, nextProps) => {
  // Return true if props are equal (skip re-render)
  return prevProps.user.id === nextProps.user.id
      && prevProps.theme === nextProps.theme;
});

Use custom comparisons sparingly. They are easy to get wrong โ€” forgetting a prop means stale renders.

useMemo and useCallback

useMemo memoizes a computed value. useCallback memoizes a function reference. Both take a dependency array.

import { useMemo, useCallback } from 'react';

function ProductList({ products, filter, onSelect }) {
  // Memoize expensive filtering
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === filter);
  }, [products, filter]);

  // Memoize callback so memoized children do not re-render
  const handleSelect = useCallback((id) => {
    onSelect(id);
  }, [onSelect]);

  return (
    <ul>
      {filteredProducts.map(product => (
        <ProductItem
          key={product.id}
          product={product}
          onSelect={handleSelect}
        />
      ))}
    </ul>
  );
}

const ProductItem = memo(function ProductItem({ product, onSelect }) {
  return (
    <li onClick={() => onSelect(product.id)}>
      {product.name} โ€” ${product.price}
    </li>
  );
});

Decision Table: When to Use

ScenariouseMemo?useCallback?React.memo?
Expensive computation (sort, filter 1000+ items)Yesโ€”โ€”
Object/array prop passed to memoized childYesโ€”Yes (on child)
Function prop passed to memoized childโ€”YesYes (on child)
Simple component, few props, cheap renderNoNoNo
Context value that changes rarelyYesโ€”โ€”

Code Splitting with Dynamic Imports

By default, bundlers create a single JavaScript file containing your entire app. Code splitting breaks it into smaller chunks loaded on demand.

React.lazy and Suspense

import { lazy, Suspense } from 'react';

// This import is replaced with a dynamic import
// The Dashboard chunk is loaded only when needed
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Analytics = lazy(() => import('./Analytics'));

function App() {
  const [page, setPage] = useState('dashboard');

  return (
    <nav>
      <button onClick={() => setPage('dashboard')}>Dashboard</button>
      <button onClick={() => setPage('settings')}>Settings</button>
      <button onClick={() => setPage('analytics')}>Analytics</button>

      <Suspense fallback={<div className="spinner">Loading...</div>}>
        {page === 'dashboard' && <Dashboard />}
        {page === 'settings' && <Settings />}
        {page === 'analytics' && <Analytics />}
      </Suspense>
    </nav>
  );
}

Route-based Splitting in Next.js

Next.js splits by page automatically. Each file in /pages becomes its own chunk. For further splitting within a page:

import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('../components/HeavyChart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false, // skip server-side rendering for client-only components
});

export default function AnalyticsPage() {
  return (
    <div>
      <h1>Analytics</h1>
      <HeavyChart />
    </div>
  );
}

Preloading Critical Chunks

const Dashboard = lazy(() => import('./Dashboard'));

// Preload when user hovers over the nav link
function NavLink() {
  const handleMouseEnter = () => {
    import('./Dashboard'); // triggers chunk download early
  };

  return (
    <button onMouseEnter={handleMouseEnter} onClick={() => navigate('/dashboard')}>
      Dashboard
    </button>
  );
}

This bridges the gap between lazy loading and instant navigation. The chunk starts downloading on hover, so it is often ready by the time the user clicks.

Image Optimization

Images are typically the heaviest assets on a page. React apps (especially Next.js) have powerful tools for optimization.

next/image

import Image from 'next/image';

function ProductCard({ product }) {
  return (
    <div className="card">
      <Image
        src={product.imageUrl}
        alt={product.name}
        width={400}
        height={300}
        placeholder="blur"
        blurDataURL={product.blurHash}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        priority={false}
      />
      <h3>{product.name}</h3>
    </div>
  );
}

What next/image does automatically:

  • Responsive sizing โ€” generates multiple sizes, serves the smallest that fits
  • Lazy loading โ€” images below the fold load on scroll
  • WebP/AVIF conversion โ€” serves modern formats when the browser supports them
  • Blur placeholder โ€” shows a low-res preview while loading
  • Priority flag โ€” set priority on above-the-fold images (like hero banners) to preload them

Lazy Loading Without Next.js

import { useState, useRef, useEffect } from 'react';

function LazyImage({ src, alt, width, height }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: '200px' }
    );

    if (imgRef.current) observer.observe(imgRef.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={imgRef} style={{ width, height, background: '#e0e0e0' }}>
      {isInView && (
        <img
          src={src}
          alt={alt}
          width={width}
          height={height}
          onLoad={() => setIsLoaded(true)}
          style={{ opacity: isLoaded ? 1 : 0, transition: 'opacity 0.3s' }}
        />
      )}
    </div>
  );
}

Bundle Size Reduction

Large bundles mean slow initial loads. Here is how to audit and shrink them.

Analyze Your Bundle

# Next.js
ANALYZE=true npm run build

# Or use webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // your Next.js config
});

Common Bundle Bloaters

LibrarySize (gzipped)Lighter Alternative
moment.js~72 KBdate-fns (~7 KB tree-shaken), dayjs (~2 KB)
lodash (full)~72 KBlodash-es (tree-shakeable), individual imports
chart.js~60 KBlightweight-charts, uPlot
react-icons (full)~40 KBImport individual icons

Tree Shaking: Import Only What You Need

// BAD: imports entire library
import { format } from 'date-fns';
// This might still pull in everything depending on your bundler

// GOOD: direct import path
import format from 'date-fns/format';

// BAD: imports all icons
import { FaHome, FaUser } from 'react-icons/fa';

// GOOD: import from specific icon set
import { FaHome } from 'react-icons/fa';
import { FaUser } from 'react-icons/fa';

Measure Before and After

# Check individual dependency sizes
npx bundlephobia <package-name>

# Or check package.json sizes
npx cost-of-modules

Lazy Loading Routes

For single-page apps, route-based code splitting is the highest-impact optimization. Each route becomes its own chunk.

React Router with Lazy Loading

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 Blog = lazy(() => import('./pages/Blog'));
const BlogPost = lazy(() => import('./pages/BlogPost'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<GlobalSpinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/blog" element={<Blog />} />
          <Route path="/blog/:slug" element={<BlogPost />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}
Without Code Splitting:
+-----------------------------------------------------+
|  bundle.js (500 KB)                                 |
|  Home + About + Blog + BlogPost + Contact + ...     |
+-----------------------------------------------------+
User loads / --> downloads ALL 500 KB

With Code Splitting:
+-------------+  +-------------+  +-------------+
| home.js     |  | about.js    |  | blog.js     |
| (50 KB)     |  | (30 KB)     |  | (80 KB)     |
+-------------+  +-------------+  +-------------+
User loads / --> downloads only home.js (50 KB)
Navigates to /about --> downloads about.js (30 KB)

Virtualized Lists

Rendering thousands of DOM nodes is slow regardless of how optimized your React code is. Virtualization renders only the visible items plus a small buffer.

import { useRef, useState, useEffect } from 'react';

function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight / itemHeight) + 1,
    items.length
  );
  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      ref={containerRef}
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: containerHeight, overflow: 'auto' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>
              {item.name}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

For production, use react-window or @tanstack/react-virtual:

import { FixedSizeList } from 'react-window';

function UserList({ users }) {
  const Row = ({ index, style }) => (
    <div style={style} className="user-row">
      {users[index].name} โ€” {users[index].email}
    </div>
  );

  return (
    <FixedSizeList
      height={600}
      width="100%"
      itemCount={users.length}
      itemSize={50}
    >
      {Row}
    </FixedSizeList>
  );
}
Without Virtualization (10,000 items):
+-----------------------------------+
| DOM: 10,000 <div> elements        |  Slow render, high memory
| Viewport shows: ~15 items         |
+-----------------------------------+

With Virtualization (10,000 items):
+-----------------------------------+
| DOM: ~20 <div> elements           |  Fast render, low memory
| Viewport shows: ~15 items         |
| Buffer: 2-3 above + 2-3 below    |
+-----------------------------------+

Avoiding Unnecessary Re-renders

State Colocation

Move state as close to where it is used as possible. State at the top of the tree causes the entire tree to re-render.

// BAD: searchQuery state lives in App, entire tree re-renders on every keystroke
function App() {
  const [searchQuery, setSearchQuery] = useState('');
  return (
    <div>
      <Header />
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <ProductList />  {/* re-renders on every keystroke */}
      <Footer />       {/* re-renders on every keystroke */}
    </div>
  );
}

// GOOD: searchQuery state lives in SearchBar, siblings unaffected
function App() {
  return (
    <div>
      <Header />
      <SearchBar />    {/* owns its own state */}
      <ProductList />  {/* not affected */}
      <Footer />       {/* not affected */}
    </div>
  );
}

Children as Props Pattern

// BAD: ExpensiveComponent re-renders when count changes
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveComponent />
    </div>
  );
}

// GOOD: ExpensiveComponent is passed as children, does not re-render
function Counter({ children }) {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      {children}
    </div>
  );
}

function Parent() {
  return (
    <Counter>
      <ExpensiveComponent />
    </Counter>
  );
}

This works because children is created during Parent's render. When Counter re-renders due to its own state change, children is the same reference (Parent did not re-render), so React skips re-rendering ExpensiveComponent.

Chrome DevTools Profiler

The React DevTools Profiler records component render timings and helps identify what re-renders and why.

Setup

  1. Install the React Developer Tools browser extension
  2. Open DevTools, go to the Profiler tab
  3. Click the record button, interact with your app, then stop recording

Reading the Flame Graph

Flame Graph Reading Guide:

+--[ App ]----------------------------------+  15ms total
|  +--[ Header ]--------+  2ms              |
|  |  +--[ Logo ]+ 0.5ms|                   |
|  |  +--[ Nav  ]+ 1ms  |                   |
|  +---------------------+                  |
|  +--[ ProductList ]-------------+  12ms   |  <-- bottleneck
|  |  +--[ ProductItem ]+ 0.2ms   |         |
|  |  +--[ ProductItem ]+ 0.2ms   |         |
|  |  +--[ ProductItem ]+ 0.2ms   |         |
|  |  ... (200 items)             |         |
|  +------------------------------+         |
+--------------------------------------------+

Key Things to Look For

  1. Gray bars = components that did not render (good)
  2. Yellow/orange bars = slow renders (investigate)
  3. "Why did this render?" โ€” enable in Profiler settings, shows what triggered each render
  4. Ranked view โ€” sorts components by render time, fastest way to find bottlenecks

Common Findings and Fixes

Profiler FindingLikely CauseFix
Parent re-renders, all children re-renderState too high in treeColocate state, use memo
Same component renders many timesList without memoizationReact.memo + stable keys
Component renders with same propsMissing React.memoAdd React.memo
Render is slow (>16ms)Heavy computation in renderuseMemo, move logic to worker

Lighthouse Audit

Lighthouse audits your page for performance, accessibility, best practices, and SEO. Run it from Chrome DevTools (Lighthouse tab) or the CLI.

npx lighthouse https://your-app.com --view

Key Metrics

MetricTargetWhat It Measures
First Contentful Paint (FCP)< 1.8sTime until first content is visible
Largest Contentful Paint (LCP)< 2.5sTime until largest visible element loads
Total Blocking Time (TBT)< 200msTime main thread was blocked
Cumulative Layout Shift (CLS)< 0.1Visual stability (layout jumps)
Time to Interactive (TTI)< 3.8sTime until page is fully interactive

Quick Wins from Lighthouse

// 1. Add width and height to images (prevents CLS)
<img src="/photo.jpg" alt="..." width={800} height={600} />

// 2. Preload critical fonts
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossOrigin="" />

// 3. Defer non-critical JavaScript
<script src="/analytics.js" defer />

// 4. Use loading="lazy" on below-fold images
<img src="/photo.jpg" alt="..." loading="lazy" width={800} height={600} />

Identifying and Fixing Bottlenecks

A systematic approach to performance debugging:

Step 1: Establish Baseline

# Run Lighthouse in CI for every build
npx lighthouse https://staging.your-app.com \
  --output=json \
  --output-path=./lighthouse-report.json

Step 2: Profile in Development

Use React DevTools Profiler to record interactions. Focus on:

  • Page loads
  • Form submissions
  • List scrolling
  • Tab switching

Step 3: Check the Renders

// Temporary: log every render with its cause
function DebugRenders({ name }) {
  const renderCount = useRef(0);
  renderCount.current++;
  console.log(`${name} rendered ${renderCount.current} times`);
  return null;
}

// Place inside any component you suspect
function ProductList({ products }) {
  return (
    <div>
      <DebugRenders name="ProductList" />
      {products.map(p => <ProductItem key={p.id} product={p} />)}
    </div>
  );
}

Step 4: Measure Specific Operations

function ExpensiveOperation({ data }) {
  const result = useMemo(() => {
    console.time('expensive-filter');
    const filtered = data.filter(/* complex logic */);
    console.timeEnd('expensive-filter');
    return filtered;
  }, [data]);

  return <List items={result} />;
}

Step 5: Apply Targeted Fixes

Match the bottleneck to the solution:

Bottleneck: Too many re-renders
Fix: React.memo, useCallback, state colocation

Bottleneck: Expensive computation on every render
Fix: useMemo

Bottleneck: Large bundle size
Fix: Code splitting, tree shaking, lighter libraries

Bottleneck: Rendering 1000+ DOM nodes
Fix: Virtualization (react-window)

Bottleneck: Slow images
Fix: next/image, lazy loading, modern formats

Bottleneck: Layout shifts
Fix: Explicit width/height, font-display: swap

Web Workers for Heavy Computation

When computation takes more than 16ms, it blocks the main thread and causes jank. Move it to a Web Worker.

// worker.js
self.addEventListener('message', (e) => {
  const { data, sortKey } = e.data;
  const sorted = [...data].sort((a, b) => {
    return a[sortKey] > b[sortKey] ? 1 : -1;
  });
  self.postMessage(sorted);
});
import { useState, useEffect, useRef } from 'react';

function useWorker(workerPath) {
  const workerRef = useRef(null);
  const [result, setResult] = useState(null);

  useEffect(() => {
    workerRef.current = new Worker(workerPath);
    workerRef.current.onmessage = (e) => setResult(e.data);
    return () => workerRef.current.terminate();
  }, [workerPath]);

  const postMessage = (data) => {
    workerRef.current?.postMessage(data);
  };

  return { result, postMessage };
}

function SortableTable({ data }) {
  const { result: sortedData, postMessage } = useWorker('/worker.js');

  const handleSort = (key) => {
    postMessage({ data, sortKey: key });
  };

  return (
    <table>
      <thead>
        <tr>
          <th onClick={() => handleSort('name')}>Name</th>
          <th onClick={() => handleSort('price')}>Price</th>
        </tr>
      </thead>
      <tbody>
        {(sortedData || data).map(item => (
          <tr key={item.id}>
            <td>{item.name}</td>
            <td>{item.price}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Performance Optimization Checklist

Use this as a reference when auditing React app performance:

Rendering

  • State colocated as close to usage as possible
  • React.memo on components that receive stable props
  • useMemo for expensive computations
  • useCallback for function props passed to memoized children
  • Virtualized lists for 100+ items

Loading

  • Route-based code splitting
  • Dynamic imports for heavy components
  • Image optimization (responsive sizes, lazy loading, modern formats)
  • Font preloading with font-display: swap
  • Third-party script deferral

Bundle

  • Bundle analyzer run, large dependencies identified
  • Heavy libraries replaced with lighter alternatives
  • Tree shaking verified (no unused exports in bundle)
  • No duplicate dependencies in bundle

Measuring

  • Lighthouse CI integrated in build pipeline
  • React DevTools Profiler used to identify re-render bottlenecks
  • Core Web Vitals monitored in production

Key Takeaways

  • Measure before optimizing โ€” never guess at bottlenecks
  • React.memo is only useful when props are actually stable (primitives, or memoized objects/functions)
  • Code splitting at the route level is the highest-impact optimization for most apps
  • Virtualize long lists โ€” do not render thousands of DOM nodes
  • Use next/image or manual lazy loading for images
  • Run Lighthouse audits regularly, track metrics over time
  • Move computations over 16ms to Web Workers
  • Profile in production mode โ€” development mode is significantly slower and gives misleading timings

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles