A React application that performs well in development on a developer's high-spec machine with a fast local network connection does not automatically perform well for a user on an average laptop in an office with a corporate proxy and a shared Wi-Fi network. The gap between those two environments is where React performance problems live, and they are almost never caught before production unless you deliberately measure for them.
This checklist covers the performance work that actually matters for production React applications — not micro-optimizations that improve benchmark scores by two percent, but structural decisions that determine whether the application feels fast or slow to real users.
Bundle Size: Measure First, Optimize Specific Problems
The JavaScript bundle is the first performance bottleneck for any React application. Before the application renders anything, the browser must download, parse, and execute the JavaScript bundle. A large bundle means a long wait before the user sees any content.
The first step is measuring. Run webpack-bundle-analyzer or vite-bundle-visualizer to generate a visual treemap of your bundle. This immediately shows which libraries are contributing the most bytes and which might have smaller alternatives or be importable more selectively.
Common large bundle contributors in React applications:
- Moment.js: Imported entirely but usually only one or two functions are used. Replace with date-fns or day.js, which are significantly smaller and tree-shakeable.
- Lodash: If imported as
import _ from 'lodash'the entire library is included. Use named imports fromlodash-esor replace specific functions with native JavaScript equivalents. - Icon libraries: Importing an entire icon library for five icons. Import only the specific icons used:
import { FiUser } from 'react-icons/fi'rather than the entire set. - Charting libraries: Some ship large bundles. Check whether the library supports tree-shaking and import only the chart types used.
Code Splitting: Load What Is Needed, When It Is Needed
Code splitting breaks the application bundle into smaller chunks that are loaded on demand rather than all at once on first load. React supports this natively through React.lazy and Suspense.
The most impactful code splitting boundary is the route level. Each page of the application is loaded only when the user navigates to it. A user who only ever visits the dashboard page never downloads the code for the settings page or the reports page. In a large application with many routes, route-level splitting can reduce the initial bundle by sixty to eighty percent.
Beyond routes, component-level splitting is appropriate for heavy components that are not shown immediately on page load: a rich text editor, a complex chart component, a PDF viewer, a full-screen modal with substantial logic. These can be lazy loaded and shown with a loading fallback while the component chunk downloads.
The correct pattern:
const ReportsPage = React.lazy(() => import('./pages/ReportsPage'));
<Suspense fallback={<PageSkeleton />}>
<ReportsPage />
</Suspense>
Rendering Optimization: Preventing Unnecessary Re-renders
React re-renders a component when its state or props change. In a complex component tree, a state change at the top level can trigger unnecessary re-renders of dozens of child components that did not actually need to update.
The tools for controlling re-renders:
React.memo wraps a component and prevents it from re-rendering if its props have not changed (using shallow equality comparison). It is appropriate for pure display components that receive the same props frequently — list item components, card components, table cell renderers.
useMemo memoizes an expensive computed value so it is only recalculated when its dependencies change. Use it for computations that are genuinely expensive (filtering and sorting large arrays, complex aggregations) — not for simple operations where the memoization overhead exceeds the computation cost.
useCallback memoizes a function reference so that components receiving it as a prop do not re-render due to function reference changes. It is most useful when passing callbacks to memoized child components.
Important caveat: unnecessary use of these tools adds overhead without benefit. Profile before optimizing. React DevTools Profiler shows which components are re-rendering and why, and how long each render takes. Optimize only the components that the profiler shows are genuinely causing performance problems.
List Virtualization: DOM Size Matters
Rendering a long list — a table with two hundred rows, a feed with five hundred items — into the DOM creates two problems: the initial render takes a long time because React must create and mount all those DOM nodes, and scrolling feels sluggish because the browser must manage a large number of DOM elements simultaneously.
Virtualization solves both problems by only rendering the items currently visible in the viewport. TanStack Virtual and react-window are the standard choices. With virtualization, a list of ten thousand items renders with the same performance as a list of twenty items, because only the twenty visible items exist in the DOM at any given moment.
Image Optimization: The Hidden Performance Cost
Images are frequently the largest assets on any page. An unoptimized image — a 4MB PNG used as a profile avatar or a 2MB JPG hero image — can dwarf the JavaScript bundle in terms of download size.
For React applications deployed with Next.js, the Image component handles optimization automatically: it serves WebP where the browser supports it, serves appropriately sized images based on the display size, and uses lazy loading by default for off-screen images.
For plain React applications without Next.js, use the loading="lazy" attribute on all images not visible in the initial viewport, serve images in WebP format, and use appropriately sized images for the context rather than serving a 2000px wide image and scaling it down in CSS.
Core Web Vitals: The Metrics That Matter for User Experience
Google's Core Web Vitals provide three measurable signals that correlate with how users experience a page:
- Largest Contentful Paint (LCP): How long until the largest visible element is rendered. Target: under 2.5 seconds. Affected primarily by bundle size, server response time, and image loading.
- Cumulative Layout Shift (CLS): How much the layout unexpectedly shifts as content loads. Target: under 0.1. Caused by images and embeds without explicit dimensions, and by dynamically injected content above existing content.
- Interaction to Next Paint (INP): How quickly the page responds to user interactions. Target: under 200ms. Affected by heavy JavaScript execution on the main thread and inefficient event handlers.
Measure Core Web Vitals with real user data using the web-vitals JavaScript library, which reports field measurements from actual users. Lab measurements from Lighthouse are a useful starting point but do not substitute for real user measurement on real devices and networks.
If you are building a React application that needs to perform well for real users in production conditions, visit my React and Next.js development services page to see how I approach frontend performance as part of the overall development process.