DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

React Performance Optimization: Beyond Code Splitting

React Performance Optimization: Beyond Code Splitting

Code splitting is table stakes. If you're still shipping your entire React app as one bundle, we need to talk—but that's not what this post is about. I'm assuming you've already done the obvious stuff: lazy loading routes, dynamic imports, tree shaking. What I want to cover is the debugging workflow that actually catches performance issues before they hit production, and the optimization strategies that move the needle when everything else is already optimized.

I learned this the hard way building CitizenApp. We had code splitting nailed down, but users on 4G still experienced janky interactions because we weren't tracking where React was actually spending CPU time. This post is that hard-won knowledge distilled.

The Performance Debugging Workflow That Actually Works

Before optimizing anything, you need visibility. Not just Lighthouse scores—those are lag indicators. You need to see where React is re-rendering unnecessarily and why.

Step 1: Identify the culprit with React DevTools Profiler

Open React DevTools → Profiler tab → record a user interaction. Look at the flame graph. You're looking for:

  • Renders that don't correspond to state changes you made
  • Components that render multiple times in quick succession
  • Long render times on components that should be pure
// Bad: This component re-renders whenever parent renders
function UserCard({ user, onUpdate }) {
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => onUpdate(user.id)}>Update</button>
    </div>
  );
}

// Better: Memoize if props don't change frequently
const UserCard = React.memo(({ user, onUpdate }) => {
  return (
    <div>
      <h2>{user.name}</h2>
      <button onClick={() => onUpdate(user.id)}>Update</button>
    </div>
  );
}, (prev, next) => {
  // Custom comparison: only re-render if user data actually changed
  return prev.user.id === next.user.id && prev.user.name === next.user.name;
});
Enter fullscreen mode Exit fullscreen mode

The custom comparison function is key. Default shallow comparison works 60% of the time. The other 40%, you'll be surprised what React thinks changed.

Step 2: Check your bundle with source-map-explorer

npm install --save-dev source-map-explorer
npm run build
npx source-map-explorer 'dist/**/*.js'
Enter fullscreen mode Exit fullscreen mode

I prefer source-map-explorer over webpack-bundle-analyzer because it shows you the actual code, not just filenames. You'll often find that a library you imported once is somehow included in three different chunks. Or that a dependency pulled in a massive transitive dependency you didn't know about.

Last month I found that our PDF export feature was pulling in the entire PDF.js library (4MB!) even though only 2% of users ever used it. Moving it to a dynamic import reduced our main bundle by 40%.

Step 3: Profile runtime performance with Performance tab

// Wrap expensive operations in markers
performance.mark('process-users-start');
const processed = users.map(u => ({
  ...u,
  displayName: formatName(u.firstName, u.lastName),
  initials: getInitials(u)
}));
performance.mark('process-users-end');
performance.measure('process-users', 'process-users-start', 'process-users-end');

// Check the result in DevTools Performance tab
const measure = performance.getEntriesByName('process-users')[0];
console.log(`Processing took ${measure.duration}ms`);
Enter fullscreen mode Exit fullscreen mode

This gives you frame-by-frame breakdowns. If you see 200+ millisecond tasks, you've found your problem.

Memoization Strategies That Actually Matter

React.memo gets all the attention, but I prefer useMemo and useCallback for most real-world cases. Here's why and when to use each.

Use useMemo for expensive calculations in render

function Analytics({ data }) {
  // Without useMemo, this recalculates on every render
  const stats = React.useMemo(() => {
    return {
      total: data.reduce((sum, d) => sum + d.value, 0),
      average: data.reduce((sum, d) => sum + d.value, 0) / data.length,
      percentiles: calculatePercentiles(data) // expensive
    };
  }, [data]);

  return <div>{stats.average}</div>;
}
Enter fullscreen mode Exit fullscreen mode

I put this in every component that does more than string interpolation. The memory cost is negligible, but the CPU savings are real—especially on mobile devices.

Use useCallback for event handlers passed to child components

function UserList({ users, onSelect }) {
  // Without useCallback, child components re-render even if user list didn't change
  const handleSelect = React.useCallback((userId) => {
    onSelect(userId);
    analytics.track('user-selected', { userId });
  }, [onSelect]);

  return (
    <ul>
      {users.map(u => (
        <UserItem key={u.id} user={u} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

// UserItem needs React.memo for useCallback to matter
const UserItem = React.memo(({ user, onSelect }) => (
  <li onClick={() => onSelect(user.id)}>{user.name}</li>
));
Enter fullscreen mode Exit fullscreen mode

The combination matters. useCallback alone without React.memo on the child does nothing.

Avoid excessive memoization (yes, it's possible)

Here's what burned me: I memoized everything and ended up with a slower app. The dependency array comparison itself has a cost. If a dependency changes every render, you lose.

// Bad: The dependency array changes every render
const expensiveValue = useMemo(() => {
  return processData(data);
}, [{ ...options }]); // Creates new object every render!

// Good: Stable dependency reference
const expensiveValue = useMemo(() => {
  return processData(data);
}, [data.length, options.type]); // Only primitive values that actually matter
Enter fullscreen mode Exit fullscreen mode

The Backend Connection: Where Most React Apps Actually Bottleneck

Real talk: your React app isn't slow because of React. It's slow because you're fetching too much data or waiting for a slow backend.

// Bad: Fetch user + their posts + comments on each post
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(user => {
        setUser(user);
        // Now fetch posts...
        return fetch(`/api/users/${userId}/posts`);
      })
      .then(r => r.json())
      .then(posts => setPosts(posts));
  }, [userId]);
}

// Better: One endpoint, structured data
function UserProfile({ userId }) {
  const { data: profile, isLoading } = useQuery(
    ['user-profile', userId],
    () => fetch(`/api/users/${userId}/profile?include=posts,stats`).then(r => r.json()),
    { staleTime: 5 * 60 * 1000 } // Cache for 5 minutes
  );
}
Enter fullscreen mode Exit fullscreen mode

On CitizenApp, moving to aggregate endpoints reduced Time to Interactive by 60%. More than any React optimization.

Gotcha: What I Missed

The DevTools Profiler lies (sometimes).

When you profile in development mode, React is running without optimizations. Production builds are significantly faster. I spent a week optimizing a component that showed as slow in the profiler, only to find it rendered in 2ms in production.

Always profile production builds. Use npm run build && npm run preview with your build tool, or deploy to a staging environment.

useTransition is not a silver bullet.

// I thought this would magically fix slow renders
const [isPending, startTransition] = useTransition();

const handleSort = (column) => {
  startTransition(() => {
    setSortColumn(column);
  });
};
Enter fullscreen mode Exit fullscreen mode

It doesn't. It defers the update, which is great for UX (you can show a loading state), but the underlying work still happens. Use it for perception, not performance. The actual optimization needs to be in the calculation itself.


Performance isn't a feature you add at the end. It's a debugging skill you practice continuously. Profile → identify → fix → measure → repeat. Everything else is cargo cult optimization.

Top comments (0)