React Performance Optimization: A Practical Guide for Real Apps
How to make React apps fast — measuring with the Profiler, avoiding unnecessary re-renders, memoization, code splitting, list virtualization, data fetching and bundle size.
React is fast by default for most apps. When a React app feels slow, the cause is usually a handful of specific problems: too much JavaScript, expensive renders happening too often, huge lists, or waterfalls of network requests. This guide shows how to find those problems and fix them without sprinkling useMemo everywhere.
Key takeaways
- Measure first with the React DevTools Profiler and browser performance tools. Don't optimize by guesswork.
- The biggest wins usually come from shipping less JavaScript and fixing data-fetching waterfalls, not micro-memoization.
- State placement matters: keep state as close as possible to where it's used.
- Use memoization where profiling shows expensive re-renders, not by default.
- Virtualize long lists; code-split routes and heavy components.
Step 1: Measure
React DevTools Profiler
Record an interaction and inspect which components rendered, how long each took and why they rendered (enable "Record why each component rendered"). Look for:
- Components rendering when nothing visible changed.
- A single slow component (expensive computation or huge subtree).
- Cascades: one state change re-rendering half the app.
Browser tooling
- Performance panel: long tasks, main-thread blocking, layout thrashing.
- Lighthouse and Core Web Vitals: LCP, INP and CLS measure real user experience (Core Web Vitals guide).
- Network panel: request waterfalls and bundle sizes.
Test on a mid-range phone or with CPU throttling. Your laptop hides problems your users feel.
Step 2: Ship less JavaScript
JavaScript has to be downloaded, parsed and executed before your app becomes interactive. For many apps, this is the number one performance problem.
Code-split by route:
tsximport { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
Also:
- Lazy-load heavy components that aren't needed immediately: charts, rich-text editors, maps, modals.
- Audit dependencies with a bundle analyzer. Replace heavy libraries (large date or utility libraries) with lighter alternatives or native APIs.
- Import only what you use (
import { debounce } from "..."rather than whole-library imports, when the library supports tree shaking). - Consider server rendering or static generation for content-heavy pages to improve first paint and SEO.
Step 3: Avoid unnecessary re-renders
A component re-renders when its state changes, its parent re-renders, or a context it consumes changes. Most re-renders are cheap. Optimize when profiling shows they're not.
Keep state local
tsx// ❌ Typing in the search box re-renders the entire page
function Page() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<ExpensiveDashboard />
</>
);
}
// ✅ Move state down into the component that needs it
function Page() {
return (
<>
<SearchSection /> {/* owns its own query state */}
<ExpensiveDashboard />
</>
);
}
Split contexts
A single context holding { user, theme, cart, notifications } re-renders every consumer when any field changes. Split contexts by update frequency, or use a state library with selector-based subscriptions.
Pass children to avoid re-rendering subtrees
Elements passed as children are created by the parent's parent, so they don't re-render when the wrapper's state changes:
tsxfunction Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<section>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</section>
);
}
Step 4: Memoize deliberately
React.memo: skip re-rendering a component when its props are shallowly equal. Useful for expensive components that receive stable props.useMemo: cache an expensive computed value.useCallback: keep a function reference stable, mainly so a memoized child doesn't re-render.
tsxconst filtered = useMemo(
() => rows.filter((r) => r.name.toLowerCase().includes(query.toLowerCase())),
[rows, query],
);
Memoization has a cost (memory, comparison work, code complexity) and breaks silently when a dependency is a new object every render. Apply it where the Profiler points. The React Compiler can automate much of this memoization in projects that adopt it, but it doesn't remove the need to measure.
Step 5: Virtualize long lists
Rendering 5,000 table rows creates 5,000 DOM subtrees. Virtualization renders only the visible rows plus a small buffer, keeping DOM size constant regardless of data size. Use a virtualization library for long lists, tables and feeds. Alternatively, paginate or use "load more", which is often better UX anyway.
Step 6: Fix data-fetching waterfalls
A classic slow pattern: component A fetches, renders child B, which then fetches, renders C, which fetches again. Three sequential round trips.
Fixes:
- Fetch in parallel at the route level, rather than cascading through nested components.
- Use a data-fetching library with caching, deduplication and background refresh, so revisiting a page is instant.
- Prefetch on hover or intent for likely next pages.
- Shape APIs to return what a screen needs in one request (a BFF or GraphQL: REST vs GraphQL vs gRPC).
Step 7: Keep interactions responsive
For Interaction to Next Paint (INP):
- Avoid long synchronous work in event handlers. Break it up or move it to a Web Worker.
- Use
useTransitionoruseDeferredValueto keep typing responsive while expensive updates render in the background:
tsxconst [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => search(data, deferredQuery), [data, deferredQuery]);
- Debounce expensive work triggered by rapid input.
Step 8: Images, fonts and layout
- Serve responsive, modern-format images (WebP/AVIF), with explicit dimensions to avoid layout shift.
- Lazy-load below-the-fold images; prioritize the hero image.
- Load fonts efficiently (
font-display: swap, preload critical fonts, subset where possible). - Avoid layout thrashing: don't interleave DOM reads and writes in loops.
React performance checklist
- Profiled on a throttled CPU / mid-range device
- Routes and heavy components code-split
- Bundle analyzed; heavy dependencies replaced or lazy-loaded
- State colocated; contexts split by update frequency
- Memoization applied where the Profiler showed benefit
- Long lists virtualized or paginated
- No request waterfalls on key pages; data cached
- Expensive updates deferred or moved off the main thread
- Images sized, compressed and lazy-loaded
FAQ
Should I wrap every component in React.memo?
No. It adds comparison overhead and complexity, and often does nothing because props change every render anyway. Use it on components that are expensive to render and receive stable props, as confirmed by profiling.
Is React slower than other frameworks?
For typical business apps, the framework is rarely the bottleneck. Bundle size, data fetching and rendering too much at once matter far more. Well-built React apps can be very fast.
Does server-side rendering make React apps faster?
It usually improves time to first content and SEO, since HTML arrives ready to display. It doesn't automatically improve interactivity: large JavaScript bundles still need to load and hydrate. Combine SSR with code splitting.
How do I find what's causing re-renders?
Use the React DevTools Profiler with "why did this render" enabled. It shows whether a render came from props, state, context or a parent, which points directly at the fix.