DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

React Performance Optimization: The Ultimate Guide — From Quick Wins to Advanced Techniques

React Performance Optimization: The Ultimate Guide — From Quick Wins to Advanced Techniques

Your React app is slow. Maybe it stutters when you type in a search box. Maybe the page takes 6 seconds to load. Maybe that list of 10,000 items makes the browser beg for mercy.

You've probably heard "just use useMemo" or "wrap it in React.memo" — but those are band-aids. Real performance optimization starts with understanding why things are slow and measuring before you optimize.

This guide covers everything: from the rendering model that makes React tick, to quick wins you can ship today, to advanced techniques that make apps genuinely fast. With real code, real metrics, and real opinions about what actually matters.


Table of Contents


Why React Apps Get Slow

Before we fix anything, let's understand the three main categories of slowness.

+------------------------------------------------------------------+
|                    WHY REACT APPS GET SLOW                        |
+------------------------------------------------------------------+
|                                                                    |
|  +---------------------+  +-----------------+  +----------------+ |
|  | Unnecessary         |  | Large Bundle    |  | Blocking the   | |
|  | Re-renders          |  | Size            |  | Main Thread    | |
|  +---------------------+  +-----------------+  +----------------+ |
|                                                                    |
|  Component renders when   User downloads 2MB   Heavy computation  |
|  nothing actually changed of JS before seeing  blocks painting    |
|                            anything             and interaction    |
|                                                                    |
|  Impact: Laggy UI,        Impact: Slow initial Impact: Frozen UI, |
|  slow interactions        load, poor LCP       bad INP/FID        |
|                                                                    |
+------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Here's the uncomfortable truth: most React performance problems are unnecessary re-renders. The component tree re-renders way more than it needs to because of how React's rendering model works. Let's understand that first.


Understanding React's Rendering Model

The Render Cycle

React rendering has three phases:

1. TRIGGER                2. RENDER               3. COMMIT
+-----------------+       +-----------------+     +-----------------+
| State changes   | ----> | React calls     | --> | React updates   |
| (setState,      |       | component       |     | the DOM with    |
|  context change,|       | functions,      |     | the minimal     |
|  parent render) |       | diffs virtual   |     | changes needed  |
|                 |       | DOM trees       |     |                 |
+-----------------+       +-----------------+     +-----------------+
                          (can be expensive         (usually fast)
                           if many components)
Enter fullscreen mode Exit fullscreen mode

The Re-render Cascade

This is the key thing most people miss: when a component re-renders, ALL its children re-render too, regardless of whether their props changed.

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <Header />          {/* Re-renders! Even though it takes no props */}
      <Counter count={count} onClick={() => setCount(c => c + 1)} />
      <Sidebar />         {/* Re-renders! Even though nothing changed */}
      <Footer />          {/* Re-renders! Totally unrelated to count */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every time count changes, App re-renders, and every child gets called again. React will diff the virtual DOM and skip unnecessary DOM updates, but the JavaScript execution of rendering those components still happens. For complex component trees, this adds up fast.

Batched Updates

React 18+ automatically batches state updates, even in async code:

// React 18: Both updates are batched into ONE re-render
async function handleClick() {
  const data = await fetchData();
  setLoading(false);   // Doesn't trigger render yet
  setData(data);       // Both batched -> single render
}

// Before React 18, async code caused TWO renders
// Now it's always batched. Nice.
Enter fullscreen mode Exit fullscreen mode

Measuring Performance — React DevTools Profiler

The golden rule of performance: measure first, optimize second. Never optimize based on guesses.

Setting Up the Profiler

  1. Install React DevTools browser extension
  2. Open DevTools -> Profiler tab
  3. Click "Record", interact with your app, click "Stop"

Reading Flame Graphs

The Profiler shows a flame graph like this:

+--[ App: 12ms ]---------------------------------------------+
|  +--[ Header: 0.5ms ]--+  +--[ Main: 11ms ]-----------+   |
|  |                      |  |  +--[ UserList: 10ms ]--+ |   |
|  +----------------------+  |  |  +--[UserCard: 2ms]-+| |   |
|                            |  |  +--[UserCard: 2ms]-+| |   |
|                            |  |  +--[UserCard: 2ms]-+| |   |
|                            |  |  +--[UserCard: 2ms]-+| |   |
|                            |  |  +--[UserCard: 2ms]-+| |   |
|                            |  +----------------------+  |   |
|                            +----------------------------+   |
+-------------------------------------------------------------+

Wider bars = more time. Colors:
  Gray  = did not render
  Blue  = rendered (fast)
  Yellow/Orange = rendered (slow, investigate!)
Enter fullscreen mode Exit fullscreen mode

What to Look For

Signal Problem Fix
Gray components that keep turning blue Unnecessary re-renders React.memo, state colocation
One component consistently yellow/orange Expensive render Memoize calculations, virtualize lists
Many small renders adding up Death by a thousand cuts Component splitting, context optimization
Large gap between render and commit Heavy DOM updates Virtualization, CSS containment

Why Did This Render?

Enable "Record why each component rendered" in Profiler settings. You'll see exactly why:

UserCard rendered because:
  - Props changed: (onClick)    <-- Likely an inline function

Sidebar rendered because:
  - Parent component rendered   <-- Probably unnecessary
Enter fullscreen mode Exit fullscreen mode

Quick Wins

These are the low-hanging fruit. High impact, low effort.

React.memo — Preventing Unnecessary Re-renders

React.memo skips re-rendering a component if its props haven't changed (shallow comparison).

// WITHOUT memo: Sidebar re-renders every time App renders
function Sidebar({ categories }) {
  console.log('Sidebar rendered!'); // Logs on EVERY App render
  return (
    <nav>
      {categories.map(c => <a key={c.id} href={c.url}>{c.name}</a>)}
    </nav>
  );
}

// WITH memo: Only re-renders when categories actually changes
const Sidebar = React.memo(function Sidebar({ categories }) {
  console.log('Sidebar rendered!'); // Only logs when categories changes
  return (
    <nav>
      {categories.map(c => <a key={c.id} href={c.url}>{c.name}</a>)}
    </nav>
  );
});
Enter fullscreen mode Exit fullscreen mode

When NOT to Use React.memo

React.memo isn't free — it adds a shallow comparison on every render. Don't use it when:

// DON'T memo these:

// 1. Component that almost always receives new props
const Timestamp = React.memo(({ time }) => <span>{time}</span>);
// time changes every second — memo comparison is wasted work

// 2. Component that's super cheap to render
const Dot = React.memo(({ color }) => (
  <div style={{ width: 8, height: 8, background: color, borderRadius: '50%' }} />
));
// Rendering a single div is faster than the memo comparison

// 3. Component that receives children (children are new objects every render)
const Card = React.memo(({ children }) => <div className="card">{children}</div>);
// children is a new React element every time — memo never skips
Enter fullscreen mode Exit fullscreen mode

The Reference Equality Gotcha

This is the #1 reason React.memo "doesn't work" for people:

function App() {
  const [count, setCount] = useState(0);

  // BUG: New object created every render -> memo comparison fails!
  const style = { color: 'red', fontSize: 16 };

  // BUG: New function created every render -> memo comparison fails!
  const handleClick = () => console.log('clicked');

  // BUG: New array created every render!
  const items = data.filter(d => d.active);

  return (
    <MemoizedChild
      style={style}           // New reference every time!
      onClick={handleClick}   // New reference every time!
      items={items}           // New reference every time!
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The fix: useMemo and useCallback.

useMemo and useCallback — Done Right

function App() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState(initialData);

  // Stable object reference — only changes if dependencies change
  const style = useMemo(() => ({ color: 'red', fontSize: 16 }), []);

  // Stable function reference
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  // Memoize filtered/computed data
  const activeItems = useMemo(
    () => data.filter(d => d.active),
    [data] // Only recompute when data changes
  );

  return (
    <MemoizedChild
      style={style}
      onClick={handleClick}
      items={activeItems}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The Overuse Anti-Pattern

Don't memoize everything. It adds complexity and memory overhead.

// OVERKILL: Don't memoize cheap computations
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// String concatenation is instant. Just write: const fullName = `${first} ${last}`;

// OVERKILL: Don't useCallback for handlers passed to native elements
const handleChange = useCallback((e) => {
  setValue(e.target.value);
}, []);
// <input onChange={handleChange} /> — native elements don't use React.memo

// WORTH IT: Memoize expensive computations
const sortedData = useMemo(
  () => [...hugeArray].sort((a, b) => a.score - b.score),
  [hugeArray]
);

// WORTH IT: Stable reference passed to memoized child
const handleSubmit = useCallback((formData) => {
  submitToAPI(formData);
}, []);
// <MemoizedForm onSubmit={handleSubmit} />
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: Use useMemo/useCallback when the value is passed as a prop to a memoized child, or when the computation is genuinely expensive (sorting, filtering large arrays, complex calculations).

The Key Prop — Why Stable Keys Matter

// BAD: Using index as key
{items.map((item, index) => (
  <ListItem key={index} item={item} />
))}
// Problem: If items are reordered, inserted, or deleted,
// React matches by index, not identity.
// This causes incorrect state preservation and extra DOM work.

// GOOD: Use a stable unique identifier
{items.map(item => (
  <ListItem key={item.id} item={item} />
))}
// React correctly tracks which item is which across renders.
Enter fullscreen mode Exit fullscreen mode

Here's what goes wrong with index keys:

Before delete:           After deleting "Bob" (index 1):

Index 0: Alice (key=0)   Index 0: Alice (key=0) -> No change, correct
Index 1: Bob   (key=1)   Index 1: Charlie (key=1) -> React thinks Bob updated!
Index 2: Charlie (key=2) -> React thinks Charlie was removed!

With stable IDs:
id_1: Alice (key=id_1)   id_1: Alice (key=id_1) -> No change
id_2: Bob   (key=id_2)   id_3: Charlie (key=id_3) -> No change
id_3: Charlie (key=id_3) -> React correctly removes id_2
Enter fullscreen mode Exit fullscreen mode

Avoiding Inline Objects and Functions in JSX

Every inline object or function creates a new reference each render:

// BAD: Creates new object and function every render
function UserProfile({ user }) {
  return (
    <div>
      <Avatar
        style={{ width: 48, height: 48 }}          // New object each render
        onClick={() => navigate(`/users/${user.id}`)} // New function each render
      />
      <UserStats data={{ posts: user.posts, likes: user.likes }} />
    </div>
  );
}

// GOOD: Hoist constants, memoize what changes
const avatarStyle = { width: 48, height: 48 }; // Defined once, outside component

function UserProfile({ user }) {
  const handleAvatarClick = useCallback(() => {
    navigate(`/users/${user.id}`);
  }, [user.id]);

  const statsData = useMemo(
    () => ({ posts: user.posts, likes: user.likes }),
    [user.posts, user.likes]
  );

  return (
    <div>
      <Avatar style={avatarStyle} onClick={handleAvatarClick} />
      <UserStats data={statsData} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Component Architecture for Performance

How you structure your components has a massive impact on performance. These patterns prevent re-renders at the architectural level.

State Colocation — Keep State Close

The most impactful pattern. If state only affects one part of the tree, put it there — not at the top.

// BAD: Search state lives in App, causing everything to re-render on each keystroke
function App() {
  const [searchQuery, setSearchQuery] = useState('');

  return (
    <div>
      <Header />   {/* Re-renders on every keystroke! */}
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <ProductGrid />  {/* Re-renders on every keystroke! */}
      <Footer />   {/* Re-renders on every keystroke! */}
    </div>
  );
}

// GOOD: Search state colocated inside SearchBar
function App() {
  return (
    <div>
      <Header />
      <SearchBar />    {/* Only this re-renders when typing */}
      <ProductGrid />
      <Footer />
    </div>
  );
}

function SearchBar() {
  const [searchQuery, setSearchQuery] = useState('');

  // Debounce before lifting results up (if needed)
  const debouncedSearch = useDebouncedCallback((query) => {
    onSearch(query); // Only triggers parent update after debounce
  }, 300);

  return (
    <input
      value={searchQuery}
      onChange={(e) => {
        setSearchQuery(e.target.value);
        debouncedSearch(e.target.value);
      }}
      placeholder="Search products..."
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Component Splitting — Extract Frequently Updating Parts

// BAD: Clock updates every second, re-rendering everything in the dashboard
function Dashboard() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Current time: {time.toLocaleTimeString()}</p>
      <ExpensiveChart data={salesData} />       {/* Re-renders every second! */}
      <ExpensiveTable data={recentOrders} />    {/* Re-renders every second! */}
    </div>
  );
}

// GOOD: Extract the clock into its own component
function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Clock />                                 {/* Only this updates each second */}
      <ExpensiveChart data={salesData} />       {/* Stable, doesn't re-render */}
      <ExpensiveTable data={recentOrders} />    {/* Stable, doesn't re-render */}
    </div>
  );
}

function Clock() {
  const [time, setTime] = useState(new Date());
  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);
  }, []);
  return <p>Current time: {time.toLocaleTimeString()}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Children as Props Pattern — Avoiding Re-render Propagation

This pattern is elegant and underused. Components passed as children don't re-render when the parent's state changes, because they were created above the state change.

// BAD: ScrollTracker owns state -> children re-render on scroll
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <ScrollIndicator position={scrollY} />
      <ExpensiveContent />    {/* Re-renders on every scroll! */}
    </div>
  );
}

// GOOD: Accept children — they're created outside, so they don't re-render
function ScrollTracker({ children }) {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener('scroll', handler);
    return () => window.removeEventListener('scroll', handler);
  }, []);

  return (
    <div>
      <ScrollIndicator position={scrollY} />
      {children}              {/* Does NOT re-render on scroll! */}
    </div>
  );
}

// Usage
function Page() {
  return (
    <ScrollTracker>
      <ExpensiveContent />   {/* Created here, doesn't know about scroll state */}
    </ScrollTracker>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why does this work? Because <ExpensiveContent /> is created in Page, not in ScrollTracker. When ScrollTracker re-renders due to scrollY changing, children is the same React element reference — so React skips re-rendering it.


Bundle Optimization

Your users download your entire JavaScript bundle before they can interact with anything. Smaller bundle = faster load.

Code Splitting with React.lazy + Suspense

// BEFORE: Everything in one bundle
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Analytics from './pages/Analytics';

// AFTER: Each page loads only when needed
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));
const Analytics = React.lazy(() => import('./pages/Analytics'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/analytics" element={<Analytics />} />
      </Routes>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode
BEFORE code splitting:
+-----------------------------------------------+
|              main.bundle.js (2.1 MB)           |
| Dashboard + Settings + Analytics + everything  |
+-----------------------------------------------+
User downloads ALL 2.1 MB on first visit.

AFTER code splitting:
+------------------+
| main.bundle.js   |  (400 KB — shared code)
+------------------+
       |
       +---> dashboard.chunk.js  (300 KB, loaded on /)
       +---> settings.chunk.js   (200 KB, loaded on /settings)
       +---> analytics.chunk.js  (600 KB, loaded on /analytics)

User downloads 400 KB + 300 KB = 700 KB on first visit.
Settings and Analytics load on demand.
Enter fullscreen mode Exit fullscreen mode

Dynamic Imports for Heavy Libraries

// DON'T import heavy libraries at the top level
import { parse } from 'csv-parse';        // 150 KB added to main bundle
import { Chart } from 'chart.js';          // 200 KB added to main bundle

// DO import them dynamically when needed
function ExportButton({ data }) {
  const handleExport = async () => {
    const { parse } = await import('csv-parse');
    const csv = parse(data);
    downloadFile(csv, 'export.csv');
  };

  return <button onClick={handleExport}>Export CSV</button>;
}

// Lazy load heavy components
const ChartView = React.lazy(() => import('./ChartView'));

function Dashboard({ showChart }) {
  return (
    <div>
      <Stats />
      {showChart && (
        <Suspense fallback={<ChartSkeleton />}>
          <ChartView />
        </Suspense>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Tree Shaking — What Breaks It

Tree shaking removes unused code from your bundle. But certain patterns prevent it from working.

// BARREL FILES BREAK TREE SHAKING
// utils/index.js (barrel file)
export { formatDate } from './date';
export { formatCurrency } from './currency';
export { parseCSV } from './csv';           // 150 KB library
export { generatePDF } from './pdf';        // 300 KB library

// When you import one thing:
import { formatDate } from './utils';
// Bundler MIGHT pull in everything because it can't prove
// the other exports don't have side effects.

// BETTER: Import directly from the source
import { formatDate } from './utils/date';
// Only date utility is included. CSV and PDF stay out.

// NAMED IMPORTS ENABLE TREE SHAKING
import { debounce } from 'lodash-es';  // Only debounce (~1 KB)
// vs
import _ from 'lodash';                // Entire library (~70 KB)
Enter fullscreen mode Exit fullscreen mode

Analyzing Your Bundle

# With webpack
npx webpack-bundle-analyzer stats.json

# With Next.js
ANALYZE=true next build

# With Vite
npx vite-bundle-visualizer
Enter fullscreen mode Exit fullscreen mode
Bundle analysis shows:

+--[main.js 800KB]--------------------------------------+
|  +--[react-dom 130KB]--+  +--[moment.js 230KB!!]--+  |
|  |                      |  |  Do you really need   |  |
|  |  Can't avoid this    |  |  ALL locales? Use     |  |
|  |                      |  |  date-fns instead     |  |
|  +----------------------+  +-----------------------+  |
|  +--[lodash 70KB]-------+  +--[your-code 200KB]---+  |
|  |  Import individual   |  |                      |  |
|  |  functions instead   |  |  This is fine.       |  |
|  +----------------------+  +----------------------+  |
+-------------------------------------------------------+

Common offenders:
  moment.js (230 KB) -> date-fns (30 KB for what you use)
  lodash (70 KB)     -> lodash-es + named imports (2 KB)
  chart.js (200 KB)  -> Lazy load it
Enter fullscreen mode Exit fullscreen mode

Rendering Optimization

For when you've fixed re-renders and bundle size, but rendering itself is still slow.

Virtualization for Long Lists

Rendering 10,000 DOM nodes is slow no matter what. Virtualization only renders what's visible.

// WITHOUT virtualization: 10,000 DOM nodes in the page
function UserList({ users }) {
  return (
    <div>
      {users.map(user => (      // 10,000 <div>s in the DOM!
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

// WITH virtualization: Only ~20 visible items rendered
import { useVirtualizer } from '@tanstack/react-virtual';

function UserList({ users }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: users.length,       // Total items: 10,000
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,    // Estimated row height in px
    overscan: 5,               // Render 5 extra items above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: `${virtualItem.size}px`,
              width: '100%',
            }}
          >
            <UserCard user={users[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Without virtualization:              With virtualization:
+--[Viewport]--+                     +--[Viewport]--+
| Item 1       |  <- visible         | Item 42      |  <- visible
| Item 2       |  <- visible         | Item 43      |  <- visible
| Item 3       |  <- visible         | Item 44      |  <- visible
+--------------+                     | Item 45      |  <- visible
| Item 4       |  <- rendered        +--------------+
| Item 5       |     but hidden      Only these ~10 items exist
| ...          |                     in the DOM. Everything else
| Item 9,999   |                     is calculated on the fly.
| Item 10,000  |
+--------------+
10,000 DOM nodes                     ~10 DOM nodes
Enter fullscreen mode Exit fullscreen mode

Debouncing and Throttling Expensive Renders

import { useDeferredValue, useMemo } from 'react';

// Option 1: useDeferredValue (React 18+)
function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = useMemo(
    () => expensiveFilter(allItems, deferredQuery),
    [deferredQuery]
  );

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      {results.map(item => <ResultCard key={item.id} item={item} />)}
    </div>
  );
}

// Option 2: Manual debounce for search input
function SearchInput({ onSearch }) {
  const [inputValue, setInputValue] = useState('');

  // Debounce the actual search, not the input display
  useEffect(() => {
    const timer = setTimeout(() => {
      onSearch(inputValue);
    }, 300);

    return () => clearTimeout(timer);
  }, [inputValue, onSearch]);

  return (
    <input
      value={inputValue}
      onChange={(e) => setInputValue(e.target.value)}
      placeholder="Type to search..."
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

useTransition — Non-Blocking Updates

useTransition lets you mark state updates as "low priority" so they don't block user input.

function TabContainer() {
  const [activeTab, setActiveTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  const handleTabChange = (tab) => {
    // The tab highlight updates immediately
    setActiveTab(tab);

    // But the expensive content render is non-blocking
    startTransition(() => {
      setActiveTab(tab); // This update can be interrupted
    });
  };

  return (
    <div>
      <TabBar activeTab={activeTab} onTabChange={handleTabChange} />
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {isPending && <Spinner />}
        <TabContent tab={activeTab} />  {/* Expensive render */}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
Without useTransition:
User clicks tab -> [=====RENDER BLOCKS=====] -> UI updates
                   Input frozen for 300ms

With useTransition:
User clicks tab -> Tab highlight updates instantly
                -> [==RENDER IN BACKGROUND==] -> Content updates
                   Input remains responsive
Enter fullscreen mode Exit fullscreen mode

Suspense Boundaries for Streaming

// Strategic Suspense boundaries = progressive loading
function Dashboard() {
  return (
    <div className="dashboard">
      {/* Critical: loads first */}
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>

      <div className="dashboard-grid">
        {/* Important: loads second */}
        <Suspense fallback={<StatsSkeleton />}>
          <StatsCards />
        </Suspense>

        {/* Below the fold: loads last */}
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<TableSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>
    </div>
  );
}

// Each section can load independently without blocking others
Enter fullscreen mode Exit fullscreen mode

Image Optimization

Images are usually the largest assets on a page. Optimizing them has an outsized impact on LCP.

The Basics

// BAD: Unoptimized image
<img src="/hero-image.png" />
// - Full resolution (3000x2000) loaded on mobile
// - No lazy loading
// - No size hints (causes layout shift = bad CLS)
// - PNG instead of modern format

// GOOD: Optimized with next/image
import Image from 'next/image';

<Image
  src="/hero-image.jpg"
  alt="Hero banner"
  width={1200}
  height={600}
  priority                  // LCP image: preload it
  sizes="(max-width: 768px) 100vw, 1200px"
  // Automatically:
  // - Serves WebP/AVIF
  // - Responsive srcset
  // - Lazy loads by default (unless priority)
  // - Prevents layout shift (width/height reserved)
/>
Enter fullscreen mode Exit fullscreen mode

Manual Optimization (Without Next.js)

function OptimizedImage({ src, alt, width, height, priority = false }) {
  return (
    <picture>
      {/* Modern format first */}
      <source srcSet={`${src}.avif`} type="image/avif" />
      <source srcSet={`${src}.webp`} type="image/webp" />

      <img
        src={`${src}.jpg`}
        alt={alt}
        width={width}
        height={height}
        loading={priority ? 'eager' : 'lazy'}
        decoding="async"
        fetchPriority={priority ? 'high' : 'auto'}
        style={{ maxWidth: '100%', height: 'auto' }}
      />
    </picture>
  );
}
Enter fullscreen mode Exit fullscreen mode

Image Optimization Checklist

Technique Impact Effort
Lazy loading (loading="lazy") High Low
Modern formats (WebP/AVIF) High Medium
Responsive images (srcset) High Medium
Width/height attributes (prevent CLS) High Low
priority/fetchpriority for LCP image Medium Low
CDN with image transformation High Medium
Blur placeholder while loading Medium Low

State Management Performance

The Context Re-render Problem

React Context triggers a re-render for every consumer when the context value changes — even if the consumer only uses a part of the value that didn't change.

// BAD: Monolithic context — everything re-renders on any change
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [notifications, setNotifications] = useState([]);

  const value = {
    user, setUser,
    theme, setTheme,
    notifications, setNotifications,
  };

  return (
    <AppContext.Provider value={value}>
      {children}
    </AppContext.Provider>
  );
}

// Problem: When notifications update, EVERY component that
// uses AppContext re-renders — even those that only read theme!
Enter fullscreen mode Exit fullscreen mode
// GOOD: Split contexts by update frequency
const UserContext = createContext();
const ThemeContext = createContext();
const NotificationContext = createContext();

function AppProvider({ children }) {
  return (
    <UserContext.Provider value={useUserState()}>
      <ThemeContext.Provider value={useThemeState()}>
        <NotificationContext.Provider value={useNotificationState()}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// Now theme consumers don't re-render when notifications change
Enter fullscreen mode Exit fullscreen mode

Zustand — Selectors for Surgical Re-renders

Zustand lets components subscribe to specific slices of state. Only the slice changing triggers a re-render.

import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  theme: 'light',
  notifications: [],
  cartItems: [],

  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
  addNotification: (n) => set((s) => ({
    notifications: [...s.notifications, n]
  })),
  addToCart: (item) => set((s) => ({
    cartItems: [...s.cartItems, item]
  })),
}));

// Component only re-renders when theme changes
function ThemeSwitcher() {
  const theme = useStore((state) => state.theme);
  const setTheme = useStore((state) => state.setTheme);
  // Notifications changing? Cart updating? This component doesn't care.
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}

// Component only re-renders when cart changes
function CartBadge() {
  const itemCount = useStore((state) => state.cartItems.length);
  return <span className="badge">{itemCount}</span>;
}
Enter fullscreen mode Exit fullscreen mode

Jotai — Atomic State

Jotai takes it further with atoms — each piece of state is independent.

import { atom, useAtom, useAtomValue } from 'jotai';

// Define atoms
const userAtom = atom(null);
const themeAtom = atom('light');
const cartAtom = atom([]);

// Derived atom — computed from other atoms
const cartTotalAtom = atom((get) => {
  const cart = get(cartAtom);
  return cart.reduce((sum, item) => sum + item.price, 0);
});

// Components subscribe to exactly the atoms they need
function CartTotal() {
  const total = useAtomValue(cartTotalAtom);
  // Only re-renders when cart changes, nothing else
  return <span>${total.toFixed(2)}</span>;
}
Enter fullscreen mode Exit fullscreen mode

Network Optimization

React Query (TanStack Query) — Smart Caching

import { useQuery, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,  // Data is fresh for 5 minutes (no refetch)
    gcTime: 30 * 60 * 1000,    // Keep in cache for 30 minutes
  });

  if (isLoading) return <ProfileSkeleton />;
  if (error) return <ErrorState error={error} />;

  return <Profile user={data} />;
}

// Prefetching — load data before user navigates
function UserListItem({ user }) {
  const queryClient = useQueryClient();

  const handleHover = () => {
    // Start fetching profile when user hovers the link
    queryClient.prefetchQuery({
      queryKey: ['user', user.id],
      queryFn: () => fetchUser(user.id),
      staleTime: 5 * 60 * 1000,
    });
  };

  return (
    <Link to={`/users/${user.id}`} onMouseEnter={handleHover}>
      {user.name}
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

Optimistic Updates

Make the UI feel instant by updating before the server confirms.

function LikeButton({ postId, initialLikes }) {
  const queryClient = useQueryClient();

  const likeMutation = useMutation({
    mutationFn: () => likePost(postId),

    // Optimistically update the UI
    onMutate: async () => {
      await queryClient.cancelQueries({ queryKey: ['post', postId] });

      const previous = queryClient.getQueryData(['post', postId]);

      queryClient.setQueryData(['post', postId], (old) => ({
        ...old,
        likes: old.likes + 1,
        isLiked: true,
      }));

      return { previous };
    },

    // If the mutation fails, roll back
    onError: (err, variables, context) => {
      queryClient.setQueryData(['post', postId], context.previous);
      toast.error('Failed to like post');
    },

    // Always refetch to sync with server
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] });
    },
  });

  return (
    <button onClick={() => likeMutation.mutate()}>
      {likeMutation.variables ? '...' : `Like (${initialLikes})`}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode
Without optimistic updates:
Click -> [waiting 200ms...] -> UI updates
User notices the delay.

With optimistic updates:
Click -> UI updates instantly -> [server confirms in background]
         (rolls back on error)
Feels instant.
Enter fullscreen mode Exit fullscreen mode

Server Components (RSC)

React Server Components run on the server and send zero JavaScript to the client. They're the biggest paradigm shift in React since hooks.

How They Work

Traditional React (Client Components):
  Server sends HTML shell -> Client downloads JS -> Client renders
  Bundle: [React + Component code + Libraries] = Heavy

Server Components:
  Server renders component to HTML + special format
  Client receives rendered output — NO JS for that component
  Bundle: Only interactive components ship JS
Enter fullscreen mode Exit fullscreen mode

When to Use Server vs Client Components

// SERVER COMPONENT (default in Next.js App Router)
// Zero JS shipped to client
async function ProductPage({ params }) {
  // Can directly access database, file system, etc.
  const product = await db.products.findById(params.id);
  const reviews = await db.reviews.findByProductId(params.id);

  return (
    <div>
      <ProductDetails product={product} />
      <ReviewList reviews={reviews} />
      {/* Client component for interactivity */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}

// CLIENT COMPONENT (needs "use client" directive)
'use client';

function AddToCartButton({ productId }) {
  const [isAdding, setIsAdding] = useState(false);

  const handleClick = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  };

  return (
    <button onClick={handleClick} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Decision Guide

Use Server Components When Use Client Components When
Fetching data Using state (useState)
Accessing backend resources Using effects (useEffect)
Rendering static/read-only content Handling user events (onClick)
Large dependencies (markdown, syntax highlighting) Using browser APIs
Sensitive logic (API keys, DB queries) Needing real-time updates

Web Vitals — LCP, INP, CLS

Google uses Core Web Vitals as a ranking factor. They also directly impact user experience.

The Three Metrics

+----------------------------------------------------------------+
|                    CORE WEB VITALS                              |
+----------------------------------------------------------------+
|                                                                  |
|  LCP (Largest Contentful Paint)                                 |
|  "How long until the main content is visible?"                  |
|  Good: < 2.5s | Needs Work: 2.5-4s | Poor: > 4s               |
|                                                                  |
|  INP (Interaction to Next Paint)                                |
|  "How long until the UI responds after I click?"               |
|  Good: < 200ms | Needs Work: 200-500ms | Poor: > 500ms         |
|  (Replaced FID in March 2024)                                   |
|                                                                  |
|  CLS (Cumulative Layout Shift)                                  |
|  "How much does stuff jump around while loading?"              |
|  Good: < 0.1 | Needs Work: 0.1-0.25 | Poor: > 0.25            |
|                                                                  |
+----------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

Improving LCP

LCP is usually an image or a large text block. Here's how to fix it:

// 1. Preload your LCP image
<head>
  <link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
</head>

// 2. Use priority on LCP image
<Image src="/hero.webp" priority />

// 3. Inline critical CSS (don't block render on external stylesheets)

// 4. Server-render the above-the-fold content (SSR/RSC)

// 5. Avoid client-side data fetching for LCP content
// BAD: Show spinner while fetching
function Hero() {
  const { data, isLoading } = useQuery({ queryKey: ['hero'], queryFn: fetchHero });
  if (isLoading) return <Spinner />; // LCP delayed until fetch completes
  return <h1>{data.title}</h1>;
}

// GOOD: Server-render it or use SSG
async function Hero() {
  const data = await fetchHero(); // Runs on server
  return <h1>{data.title}</h1>;   // Included in initial HTML
}
Enter fullscreen mode Exit fullscreen mode

Improving INP

INP measures the worst interaction responsiveness. Usually caused by heavy JavaScript on the main thread.

// BAD: Blocking the main thread on click
function FilterPanel({ items }) {
  const [filters, setFilters] = useState({});

  const handleFilterChange = (key, value) => {
    setFilters(prev => ({ ...prev, [key]: value }));
    // This triggers re-render of 10,000 items synchronously
    // Main thread blocked for 300ms -> poor INP
  };

  const filtered = items.filter(item => matchesFilters(item, filters));
  return <ItemList items={filtered} />;
}

// GOOD: Use useTransition to keep interactions responsive
function FilterPanel({ items }) {
  const [filters, setFilters] = useState({});
  const [isPending, startTransition] = useTransition();

  const handleFilterChange = (key, value) => {
    setFilters(prev => ({ ...prev, [key]: value }));

    startTransition(() => {
      // Expensive re-render happens at low priority
      // Main thread stays responsive for other interactions
    });
  };

  const filtered = useMemo(
    () => items.filter(item => matchesFilters(item, filters)),
    [items, filters]
  );

  return (
    <div style={{ opacity: isPending ? 0.7 : 1 }}>
      <ItemList items={filtered} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Improving CLS

Layout shift happens when elements change size or position after they're painted.

// BAD: Image without dimensions causes layout shift
<img src="/photo.jpg" />
// Browser doesn't know the size until image loads
// Everything below shifts down -> CLS

// GOOD: Always specify dimensions
<img src="/photo.jpg" width={800} height={600} />
// Browser reserves space immediately -> no shift

// BAD: Dynamic content inserted above visible content
function Feed() {
  return (
    <div>
      {newBannerLoaded && <PromoBanner />}  {/* Pushes everything down! */}
      <FeedItems />
    </div>
  );
}

// GOOD: Reserve space for dynamic content
function Feed() {
  return (
    <div>
      <div style={{ minHeight: newBannerLoaded ? 'auto' : 80 }}>
        {newBannerLoaded && <PromoBanner />}
      </div>
      <FeedItems />
    </div>
  );
}

// BAD: Web fonts cause flash of unstyled text
// GOOD: Use font-display: swap and preload fonts
<link rel="preload" as="font" href="/fonts/inter.woff2" crossOrigin="" />
Enter fullscreen mode Exit fullscreen mode

Performance Budget

Set limits and enforce them so performance doesn't degrade over time.

Setting a Budget

// budget.json (for bundlesize or size-limit)
{
  "budgets": [
    {
      "path": "build/static/js/main.*.js",
      "maxSize": "200 KB"
    },
    {
      "path": "build/static/js/vendor.*.js",
      "maxSize": "150 KB"
    },
    {
      "path": "build/static/css/*.css",
      "maxSize": "50 KB"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Enforcing with CI

// package.json
{
  "scripts": {
    "size": "size-limit",
    "size:check": "size-limit --check"
  },
  "size-limit": [
    {
      "path": "dist/index.js",
      "limit": "200 KB",
      "gzip": true
    },
    {
      "path": "dist/vendor.js",
      "limit": "150 KB",
      "gzip": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
# .github/workflows/perf.yml
- name: Check bundle size
  run: npm run size:check

- name: Lighthouse CI
  uses: treosh/lighthouse-ci-action@v10
  with:
    budgetPath: ./budget.json
    urls: |
      https://staging.myapp.com/
      https://staging.myapp.com/dashboard
Enter fullscreen mode Exit fullscreen mode

Recommended Budgets

Metric Target Why
Main JS bundle (gzipped) < 200 KB 3G connection loads in ~2s
Total JS (gzipped) < 400 KB Including all chunks
LCP < 2.5s Google's threshold
INP < 200ms User-perceived responsiveness
CLS < 0.1 Visual stability
Time to Interactive < 3.5s User can start using the app

Real-World Optimization Case Study

Let's walk through optimizing a real e-commerce product listing page.

Before: The Problem

Page: /products (lists 500 products with filters)

Metrics BEFORE optimization:
  Bundle size:      1.8 MB (gzipped: 580 KB)
  LCP:              4.2s
  INP:              450ms (typing in search box)
  CLS:              0.35
  Time to Interactive: 5.8s

User complaints:
  - "The page takes forever to load"
  - "Search is laggy when I type"
  - "Images jump around while loading"
Enter fullscreen mode Exit fullscreen mode

Step 1: Bundle Analysis (Impact: -400 KB)

Found:
  - moment.js (230 KB) — replaced with date-fns (12 KB used)
  - Full lodash import (70 KB) — switched to individual imports (3 KB)
  - chart.js loaded on product page (200 KB) — lazy loaded (only on analytics page)

Bundle: 1.8 MB -> 1.3 MB (gzipped: 580 KB -> 380 KB)
Enter fullscreen mode Exit fullscreen mode

Step 2: Code Splitting (Impact: -250 KB initial)

// Lazy load non-critical routes
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

// Lazy load below-the-fold product reviews
const ReviewSection = lazy(() => import('./components/ReviewSection'));

// Initial load: 380 KB -> 180 KB (gzipped)
Enter fullscreen mode Exit fullscreen mode

Step 3: Fix Re-renders (Impact: INP 450ms -> 120ms)

// Problem: Search input re-renders entire product grid on every keystroke

// Fix 1: Colocate search state
// Fix 2: Debounce the filter operation
// Fix 3: Virtualize the product grid (500 items -> ~20 rendered)
// Fix 4: Wrap ProductCard in React.memo

function ProductPage() {
  const [filters, setFilters] = useState({});
  const [isPending, startTransition] = useTransition();

  const handleFilterChange = useCallback((newFilters) => {
    startTransition(() => {
      setFilters(newFilters);
    });
  }, []);

  return (
    <>
      <SearchBar onSearch={handleFilterChange} />
      <VirtualizedProductGrid filters={filters} isPending={isPending} />
    </>
  );
}

const ProductCard = React.memo(function ProductCard({ product }) {
  return (/* ... */);
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Image Optimization (Impact: LCP 4.2s -> 1.8s, CLS 0.35 -> 0.05)

// Added width/height to all images (fixes CLS)
// Used WebP with AVIF fallback
// Priority loading for first 4 product images (above the fold)
// Lazy loading for everything below the fold
// Blur placeholder for product thumbnails

<Image
  src={product.image}
  alt={product.name}
  width={300}
  height={300}
  priority={index < 4}
  placeholder="blur"
  blurDataURL={product.blurHash}
  sizes="(max-width: 768px) 50vw, 25vw"
/>
Enter fullscreen mode Exit fullscreen mode

Step 5: Server-Side Rendering (Impact: LCP 1.8s -> 1.2s)

// Moved initial data fetch to server component
// Products are included in initial HTML — no loading spinner

async function ProductPage({ searchParams }) {
  const products = await getProducts(searchParams);

  return (
    <div>
      <SearchBar />  {/* Client component for interactivity */}
      <ProductGrid initialProducts={products} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

After: The Results

Metrics AFTER optimization:
  Bundle size:         1.8 MB -> 950 KB (initial: 180 KB gzipped)
  LCP:                 4.2s -> 1.2s          (-71%)
  INP:                 450ms -> 120ms        (-73%)
  CLS:                 0.35 -> 0.05          (-86%)
  Time to Interactive: 5.8s -> 2.1s          (-64%)

+----------+--------+--------+-------------+
| Metric   | Before | After  | Improvement |
+----------+--------+--------+-------------+
| Bundle   | 580 KB | 180 KB | -69%        |
| LCP      | 4.2s   | 1.2s   | -71%        |
| INP      | 450ms  | 120ms  | -73%        |
| CLS      | 0.35   | 0.05   | -86%        |
| TTI      | 5.8s   | 2.1s   | -64%        |
+----------+--------+--------+-------------+
Enter fullscreen mode Exit fullscreen mode

The Complete Optimization Checklist

Ranked by impact — start from the top.

Tier 1: High Impact, Do These First

Optimization Impact Effort Notes
Code splitting (routes) Very High Low React.lazy + Suspense
Image optimization Very High Low-Medium Width/height, lazy load, WebP
Remove/replace heavy deps High Medium moment -> date-fns, etc.
Virtualize long lists Very High Medium If rendering 100+ items
Server-side rendering High Medium-High SSR or RSC for LCP content
State colocation High Low Move state down the tree

Tier 2: Medium Impact, Do These Next

Optimization Impact Effort Notes
React.memo on expensive components Medium-High Low With stable props
useMemo for expensive computations Medium Low Sorting, filtering large arrays
Split contexts by update frequency Medium Low Avoid monolithic context
Debounce/throttle input-driven renders Medium Low Search, resize, scroll handlers
useTransition for non-urgent updates Medium Low Filter changes, tab switches
Prefetch data on hover/focus Medium Low React Query prefetchQuery

Tier 3: Polish

Optimization Impact Effort Notes
Tree shaking (direct imports) Medium Low Avoid barrel files
Optimistic updates Medium Medium Better perceived performance
Web font optimization Low-Medium Low font-display: swap, preload
Performance budget in CI Low (prevents regression) Medium size-limit, Lighthouse CI
CSS containment Low-Medium Low contain: content for complex layouts
Dynamic imports for heavy features Medium Low PDF export, charts, editors

The Anti-Optimization List (Don't Do These)

Anti-Pattern Why
useMemo on every computation Overhead of memoization > savings on cheap ops
React.memo on everything Comparison cost on frequently-changing props is wasteful
Premature code splitting 50 tiny chunks cause more network overhead than one medium bundle
Over-abstracting for "performance" Added complexity makes code harder to maintain and debug
Optimizing without measuring You might optimize the wrong thing entirely

Remember: measure first, then optimize the bottleneck, then measure again. Performance optimization is a science, not a guessing game.


Let's Connect!

If you found this guide helpful, I'd love to connect with you! I regularly share deep dives on system design, backend engineering, and software architecture.

Connect with me on LinkedIn — let's grow together.

Drop a comment, share this with someone who needs this, and follow along for more guides like this!

Top comments (0)