React Performance Optimization: From Slow Renders to 60fps

Optimize React application performance with techniques that eliminate unnecessary re-renders, reduce bundle size, and deliver a smooth 60fps user experience. Covers React.memo, useMemo, useCallback, code splitting, virtualization, profiling, and the mental model for understanding when and why React re-renders.

React performance optimization is the discipline of transforming a React application that renders slowly, jankily, or with unresponsive UI into one that consistently achieves 60 frames per second. It matters when users perceive delays—when a list takes 300ms to update, when a modal flickers on open, when a form feels sluggish after typing. This guide addresses the full journey from diagnosing a slow render to achieving stable 60fps across interactions, navigation, and data updates.

Diagnosing the First Frame

The first frame of a React app is often the slowest. To diagnose why, begin with react-devtools in production mode and console.time() in key render paths. Run npx react-devtools to open the devtools panel and attach it to your running app.

In the app code, add timing around critical render paths:

console.time('render:UserList');
const UserList = () => {
  const users = useUsers();
  console.timeEnd('render:UserList');
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

Open the browser’s Performance tab in the DevTools and record a full page load. Look for long "Render" and "Commit" phases in the timeline. A render taking more than 16ms (1/60th of a second) is a candidate for jank.

Use React.useMemo with a console.log to trace when components re-render:

const UserProfile = ({ userId }) => {
  const user = useUser(userId);
  const memoizedUser = React.useMemo(() => ({
    name: user.name,
    avatar: user.avatar,
    email: user.email
  }), [user]);

  console.log('UserProfile rendered for', userId);

  return (
    <div>
      <h2>{memoizedUser.name}</h2>
      <img src={memoizedUser.avatar} alt={memoizedUser.name} />
    </div>
  );
};

When console.log prints multiple times for the same userId, the component is re-rendering unnecessarily.

Preventing Unnecessary Re-renders

React’s default render behavior re-evaluates every component in the tree on every state change, even when props haven’t changed. To fix this, use React.memo with explicit areEqual comparison.

const MemoizedListItem = React.memo(
  ({ user }) => (
    <li key={user.id}>
      <span>{user.name}</span>
      <small>{user.role}</small>
    </li>
  ),
  (prevProps, nextProps) => {
    return prevProps.user.id === nextProps.user.id
      && prevProps.user.name === nextProps.user.name
      && prevProps.user.role === nextProps.user.role;
  }
);

React.memo is not magic. It only prevents re-renders when the props object reference is shallowly equal. If a parent passes a new object each time, memo fails:

// This triggers unnecessary re-renders
const UserList = () => {
  const [filter, setFilter] = useState('');
  const users = useUsers(filter);

  return (
    <ul>
      {users.map(user => (
        <MemoizedListItem user={user} /> // new object every render
      ))}
    </ul>
  );
};

To fix, use useCallback to memoize the callback passed to map:

const UserList = () => {
  const [filter, setFilter] = useState('');
  const users = useUsers(filter);

  const renderItem = React.useCallback(
    (user) => <MemoizedListItem user={user} />,
    []
  );

  return (
    <ul>
      {users.map(renderItem)}
    </ul>
  );
};

useCallback returns a memoized function. When the renderItem function is passed to map, React compares the function reference. If the function is stable across renders, map avoids re-creating the list items.

Optimizing Large Lists and Infinite Scroll

For lists with thousands of items, the bottleneck is not just re-renders but layout thrashing and memory pressure. Use React.useMemo to precompute the list of items:

const UserList = ({ filter, limit }) => {
  const users = useUsers(filter, limit);
  const renderedUsers = React.useMemo(
    () => users.map(user => (
      <MemoizedListItem key={user.id} user={user} />
    )),
    [users]
  );

  return <ul>{renderedUsers}</ul>;
};

This moves the expensive map operation into a memoized value. The list renders only when users change.

For infinite scroll, use the windowing technique. Render only the visible items in the viewport. Use react-window (a real library):

npm install react-window
import { FixedSizeList as List } from 'react-window';
import { useMemo } from 'react';

const UserList = ({ users }) => {
  const Row = ({ index, style }) => (
    <div style={style}>
      <MemoizedListItem user={users[index]} />
    </div>
  );

  return (
    <List
      height={600}
      itemCount={users.length}
      itemSize={60}
      style={{ overflow: 'auto' }}
    >
      {Row}
    </List>
  );
};

react-window renders only the items in the current viewport. It uses requestAnimationFrame to schedule updates and avoids layout thrashing. The list scrolls smoothly even with 10,000 items.

Managing State Updates and Batched Rendering

React batches updates to avoid multiple re-renders per state change. But the batching is not automatic. Use React.useTransition to mark updates as non-blocking.

const SearchBar = () => {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    startTransition(() => {
      setQuery(e.target.value);
    });
  };

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleSearch}
        placeholder="Search..."
      />
      {isPending && <Spinner />}
      <UserList query={query} />
    </div>
  );
};

useTransition marks the setQuery update as a transition, which allows React to prioritize the update and defer it if the main thread is busy. The isPending flag enables UI feedback like spinners.

React.useDeferredValue complements useTransition:

const SearchBar = () => {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query, { timeoutMs: 500 });

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <UserList query={deferredQuery} />
    </div>
  );
};

useDeferredValue delays the update of deferredQuery by a timeout. While the user types, the list renders with the previous query, and only updates after a pause. This prevents the list from re-rendering on every keystroke.

Reducing Bundle Size and Initial Load

A slow render starts with a large bundle. Use webpack-bundle-analyzer to profile the output.

npx webpack-bundle-analyzer --mode production

In the generated report.html, look for large entries like react, react-dom, lodash, moment, and @babel/runtime.

Split code using React.lazy and Suspense:

const UserProfile = React.lazy(() => import('./UserProfile'));

const App = () => (
  <Suspense fallback={<Spinner />}>
    <UserProfile userId={123} />
  </Suspense>
);

React.lazy loads the component asynchronously. Suspense provides fallback UI while loading.

For large apps, use React.lazy with React.Suspense at the top level:

const App = () => (
  <React.Suspense fallback={<Spinner />}>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/users/:id" element={<UserProfile />} />
      </Routes>
    </BrowserRouter>
  </React.Suspense>
);

The initial load can be optimized further with React.preload:

React.preload(() => import('./UserProfile'));

preload triggers a fetch of the module and caches it in memory, so subsequent navigations are faster.

Synchronizing State with useReducer and useCallback

For complex state logic, use useReducer with useCallback to avoid unnecessary re-renders.

const userReducer = (state, action) => {
  switch (action.type) {
    case 'SET_USERS':
      return { ...state, users: action.users };
    case 'TOGGLE_FAVORITE':
      return {
        ...state,
        users: state.users.map(user =>
          user.id === action.userId
            ? { ...user, favorite: !user.favorite }
            : user
        )
      };
    default:
      return state;
  }
};

const UserListContainer = () => {
  const [state, dispatch] = useReducer(userReducer, { users: [] });

  const loadUsers = useCallback(async () => {
    const response = await fetch('/api/users');
    const users = await response.json();
    dispatch({ type: 'SET_USERS', users });
  }, []);

  const toggleFavorite = useCallback((userId) => {
    dispatch({ type: 'TOGGLE_FAVORITE', userId });
  }, []);

  return (
    <div>
      <button onClick={loadUsers}>Load Users</button>
      <UserList
        users={state.users}
        onToggleFavorite={toggleFavorite}
      />
    </div>
  );
};

useReducer groups related state and actions. useCallback memoizes the loadUsers and toggleFavorite functions. The UserList component re-renders only when state.users or onToggleFavorite change.

Debugging and Monitoring Performance in Production

Use performance.mark and performance.measure to trace performance in production.

// In a component
useEffect(() => {
  performance.mark('user-list-render-start');
  return () => {
    performance.mark('user-list-render-end');
    performance.measure('user-list-render', 'user-list-render-start', 'user-list-render-end');
  };
}, []);

Collect performance entries in a window.perfObserver:

const perfObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.startsWith('user-list-render')) {
      console.log('Render duration:', entry.duration, 'ms');
    }
  }
});

perfObserver.observe({ entryTypes: ['measure'] });

Use window.performance.getEntriesByType('measure') to extract and report render durations.

Finally, use React.useLayoutEffect for layout-sensitive updates:

const Avatar = ({ user }) => {
  const [size, setSize] = useState('medium');

  useLayoutEffect(() => {
    const { width, height } = canvasRef.current.getBoundingClientRect();
    // Adjust canvas size based on layout
    canvasRef.current.width = width;
    canvasRef.current.height = height;
  }, [size]);

  return <canvas ref={canvasRef} />;
};

useLayoutEffect runs synchronously after DOM mutations and before the browser paints. It’s ideal for animations, layout updates, and visual feedback.

Summary

Achieving 60fps in React requires a layered approach: diagnose render times, prevent unnecessary re-renders with memo, useCallback, and useMemo, optimize large lists with windowing, manage state updates with useTransition and useDeferredValue, split bundles with lazy and Suspense, and use useReducer and useLayoutEffect for fine-grained control. Each tool has subtle failure modes: memo fails when props are deep but not shallowly equal; useTransition does not trigger if startTransition is called in a render but not in a commit; Suspense can render fallbacks too early or late. The path from slow renders to 60fps is not a checklist—it is a continuous process of observing, measuring, and refining.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.