DEV Community

Muhammad
Muhammad

Posted on

Fixing React Performance at Scale: A Senior Engineer's Practical Playbook

Fixing React Performance at Scale: A Senior Engineer's Practical Playbook

Premature optimization is the root of all evil — but so is ignoring performance until your users are complaining. After years of working on React applications that serve millions of users, I've developed a systematic approach to diagnosing and fixing performance problems without guessing. This is that playbook.

We'll cover the full cycle: profiling, identifying root causes, and applying the right fix for the right problem. No cargo-culted useMemo everywhere, no unnecessary complexity. Just targeted solutions.


The Golden Rule Before We Start

Never optimize without measuring first. I've seen engineers spend a week wrapping everything in memo() and useMemo() only to discover the real bottleneck was a single synchronous localStorage read on every render. Profile first. Always.


Step 1: Profile with the React DevTools Profiler

Open Chrome DevTools, install the React Developer Tools extension, and navigate to the Profiler tab.

  1. Click Record (the circle icon)
  2. Perform the slow interaction
  3. Click Stop

What you're looking for:

  • Flamechart — Which component took the longest to render?
  • Ranked chart — Sort by self time to find the actual culprit vs. components that are slow only because their children are slow
  • Why did this render? — Click any bar and look at the reason. This alone saves hours of guessing.

Pay special attention to components that render with (memo) — if they're still re-rendering, their props aren't stable. We'll fix that.


Step 2: Identify Your Problem Category

Performance issues in React almost always fall into one of four categories:

Category A: Unnecessary re-renders
A parent re-renders and takes down an entire subtree that hasn't actually changed.

Category B: Expensive computations on every render
A component does heavy work (sorting, filtering, transforming) that doesn't need to happen unless specific values change.

Category C: Too many components in the render tree
Large lists, tables, or grids rendering thousands of DOM nodes.

Category D: Waterfalls and layout thrash
Sequential data fetching, or reading layout properties (offsetHeight, getBoundingClientRect) that force style recalculations.

Knowing the category determines the fix. Let's walk through each.


Step 3: Fix Category A — Unnecessary Re-renders

The Setup

Here's a classic problem. A Dashboard component holds state, and every state update causes a deeply nested ExpensiveWidget to re-render — even though it doesn't use that state.

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  return (
    <div>
      <TabBar selected={selectedTab} onSelect={setSelectedTab} />
      <ExpensiveWidget /> {/* Re-renders on every tab change. Why? */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Diagnosis

Open the Profiler, switch tabs, and click ExpensiveWidget. The "Why did this render?" panel will say: "The parent component rendered."

The Fix: Component Composition First, Memo Second

Before reaching for React.memo, ask whether you can restructure to avoid the problem entirely.

// ✅ Better: Extract state into a component that doesn't parent ExpensiveWidget
function Dashboard() {
  return (
    <div>
      <TabSection />      {/* Owns selectedTab state */}
      <ExpensiveWidget /> {/* Never re-renders due to tab changes */}
    </div>
  );
}

function TabSection() {
  const [selectedTab, setSelectedTab] = useState('overview');
  return <TabBar selected={selectedTab} onSelect={setSelectedTab} />;
}
Enter fullscreen mode Exit fullscreen mode

This is the state colocation pattern. Moving state down to the smallest component that needs it eliminates re-render cascades without any memoization overhead.

When you can't restructure (e.g., the state is genuinely needed by multiple siblings), then use React.memo:

const ExpensiveWidget = React.memo(function ExpensiveWidget({ data }) {
  // Only re-renders when `data` changes by reference
});
Enter fullscreen mode Exit fullscreen mode

Critical caveat: memo only works if props are referentially stable. If a parent passes an inline object or function, it breaks memo on every render.

// ❌ Breaks memo — new object reference on every render
<ExpensiveWidget config={{ theme: 'dark' }} />

// ✅ Stable reference
const config = useMemo(() => ({ theme: 'dark' }), []);
<ExpensiveWidget config={config} />
Enter fullscreen mode Exit fullscreen mode

Same for callbacks:

// ❌ New function reference every render
<ExpensiveWidget onUpdate={() => doSomething(id)} />

// ✅ Stable reference
const handleUpdate = useCallback(() => doSomething(id), [id]);
<ExpensiveWidget onUpdate={handleUpdate} />
Enter fullscreen mode Exit fullscreen mode

Step 4: Fix Category B — Expensive Computations

The Setup

function ProductList({ products, searchQuery }) {
  // This runs on EVERY render, even if products and searchQuery haven't changed
  const filtered = products
    .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
    .sort((a, b) => b.rating - a.rating);

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
Enter fullscreen mode Exit fullscreen mode

The Fix: useMemo

function ProductList({ products, searchQuery }) {
  const filtered = useMemo(() => {
    return products
      .filter(p => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
      .sort((a, b) => b.rating - a.rating);
  }, [products, searchQuery]); // Only recalculates when these change

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}
Enter fullscreen mode Exit fullscreen mode

When is this worth it? Run the computation in the Profiler and check its self-time. If it's consistently over 1-2ms and the component re-renders frequently, useMemo is justified. For simple operations like filtering a 10-item array, the memoization overhead often costs more than it saves.


Step 5: Fix Category C — Long Lists

Never render 10,000 DOM nodes. The browser can't handle it, and neither can React's reconciler.

The Fix: Virtualization with TanStack Virtual

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

function VirtualList({ items }) {
  const parentRef = useRef(null);

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

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <ItemRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This renders only the rows visible in the viewport plus a small overscan buffer — typically 20-40 rows instead of thousands.


Step 6: Fix Category D — Data Fetching Waterfalls

This is the most impactful fix on this list, and the most often overlooked.

The Problem

function UserProfile({ userId }) {
  const { data: user } = useQuery(['user', userId], fetchUser);

  // This query can't start until the first one finishes
  const { data: posts } = useQuery(
    ['posts', user?.id],
    () => fetchPosts(user.id),
    { enabled: !!user }
  );
}
Enter fullscreen mode Exit fullscreen mode

Two sequential network requests when they could be parallel.

The Fix: Parallel Queries

function UserProfile({ userId }) {
  const [userQuery, postsQuery] = useQueries({
    queries: [
      { queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
      { queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) },
    ],
  });

  // Both requests fire simultaneously
}
Enter fullscreen mode Exit fullscreen mode

If you're using React Server Components or a framework like Next.js, push the data fetching up to the server layer and pass it down as props. Zero waterfalls, zero loading spinners for the user.


Step 7: Measure the Improvement

After every fix, go back to the Profiler and run the same interaction.

What to check:

  • Has total render time decreased?
  • Has the number of components in the commit decreased?
  • Do previously over-rendering components now say "Did not render" in the timeline?

Document before/after numbers. This matters for PR reviews and for building your own intuition about which fixes actually move the needle.


What I Don't Do Anymore

After years of doing this, here's what I've stopped wasting time on:

useMemo on primitive values. useMemo(() => userId + '-profile', [userId]) is pure overhead.

React.memo on every component by default. Some teams add it preemptively to all components. The overhead of comparing props on every render often outweighs the cost of just re-rendering simple components.

Chasing milliseconds on non-interactive paths. A page that takes 400ms to load feels fine. Optimize interactions first — clicks, typing, scrolling — because those are what users feel.


Conclusion

React performance work is mostly detective work. Profile first, categorize the problem, apply the targeted fix, measure the result. The engineers I've seen do this well aren't the ones who memorize all the APIs — they're the ones who stay curious, trust their profiling data, and resist the urge to optimize things that don't matter yet.

The next time a component feels slow, open the Profiler before you touch the code. The answer is usually already there.

Top comments (0)