React Performance Optimization: A Comprehensive Guide

β€’8 min read

React & Next.js Performance: The Complete Guide

Performance is one of those things that seems fine β€” until it isn't. React is fast by default, but as apps grow, unnecessary re-renders, data waterfalls, bloated bundles, and unoptimised server code all compound. This guide pulls together everything that matters, ordered by impact, so you know exactly where to start.

How to Use This Guide

Every section is ranked by priority. Fix things from the top down β€” the biggest wins come first.

PriorityTopic
πŸ”΄ CRITICALEliminating Data Waterfalls
πŸ”΄ CRITICALBundle Size
🟠 HIGHServer-Side Performance
🟑 MEDIUMRe-render Optimization
🟑 MEDIUMRendering & Assets
🟒 LOW-MEDIUMJavaScript Micro-Optimisations

πŸ”΄ 1. Eliminating Data Waterfalls (CRITICAL)

A waterfall is when async operations run one after another when they could run in parallel. This is the single biggest performance killer in React/Next.js apps β€” a sequential chain of three 200ms requests costs 600ms; run them in parallel and it costs 200ms.

Use Promise.all() for independent fetches

javascript
1// ❌ Waterfall β€” 600ms total
2async function Dashboard() {
3 const user = await fetchUser();
4 const posts = await fetchPosts();
5 const stats = await fetchStats();
6}
7
8// βœ… Parallel β€” ~200ms total
9async function Dashboard() {
10 const [user, posts, stats] = await Promise.all([
11 fetchUser(),
12 fetchPosts(),
13 fetchStats(),
14 ]);
15}
UTF-8 15 Lines

Don't await what you might not need

javascript
1// ❌ Fetches data even when the feature flag is off
2async function handler() {
3 const flag = await getFeatureFlag('new-ui');
4 const data = await fetchData(); // wasted if flag is false
5 if (flag) return renderNewUI(data);
6 return renderOldUI();
7}
8
9// βœ… Only fetches when needed
10async function handler() {
11 const flag = await getFeatureFlag('new-ui');
12 if (flag) {
13 const data = await fetchData();
14 return renderNewUI(data);
15 }
16 return renderOldUI();
17}
UTF-8 17 Lines

Use Suspense boundaries to stream content independently

Wrapping independent sections in <Suspense> lets Next.js stream each section to the browser as it resolves, instead of waiting for everything before sending anything:

javascript
1export default function Page() {
2 return (
3 <>
4 <Header /> {/* Renders immediately */}
5 <Suspense fallback={<Skeleton />}>
6 <SlowFeed /> {/* Streams when ready */}
7 </Suspense>
8 <Suspense fallback={<Skeleton />}>
9 <RecommendedPosts /> {/* Streams independently */}
10 </Suspense>
11 </>
12 );
13}
UTF-8 13 Lines

πŸ”΄ 2. Bundle Size (CRITICAL)

Every byte of JavaScript must be downloaded, parsed, and executed. Smaller bundles mean faster page loads, especially on mobile and slower connections.

Avoid barrel imports

Barrel files (index.ts that re-exports everything) prevent tree-shaking and bloat bundles:

javascript
1// ❌ May pull in your entire component library
2import { Button } from '@/components';
3
4// βœ… Import the file directly
5import { Button } from '@/components/Button';
6
7// ❌ Pulls in all of lodash (~70KB)
8import { debounce } from 'lodash';
9
10// βœ… Just the function you need (~2KB)
11import debounce from 'lodash/debounce';
UTF-8 11 Lines

Lazy-load heavy components with next/dynamic

javascript
1import dynamic from 'next/dynamic';
2
3// Only downloaded when this component actually renders
4const HeavyChart = dynamic(() => import('@/components/Chart'), {
5 loading: () => <p>Loading...</p>,
6 ssr: false,
7});
8
9// Lazy-load entire routes
10const Dashboard = lazy(() => import('./pages/Dashboard'));
UTF-8 10 Lines

Prefetch on hover for perceived speed

javascript
1function NavItem({ href, children }) {
2 const router = useRouter();
3 return (
4 <a
5 href={href}
6 onMouseEnter={() => router.prefetch(href)} // Bundle loads before click
7 >
8 {children}
9 </a>
10 );
11}
UTF-8 11 Lines

🟠 3. Server-Side Performance (HIGH)

Deduplicate DB/API calls with React.cache()

When multiple Server Components on the same page need the same data, React.cache() ensures the underlying call only runs once per request:

javascript
1import { cache } from 'react';
2
3export const getUser = cache(async (id: string) => {
4 return db.user.findUnique({ where: { id } });
5});
6
7// ProfileHeader and ProfileStats both call getUser(userId)
8// but only ONE DB query runs per request
9async function ProfileHeader({ userId }) {
10 const user = await getUser(userId);
11 return <h1>{user.name}</h1>;
12}
13
14async function ProfileStats({ userId }) {
15 const user = await getUser(userId); // Cache hit β€” no extra query
16 return <p>{user.postsCount} posts</p>;
17}
UTF-8 17 Lines

Hoist static I/O to module level

Reading files (fonts, config, logos) on every request is wasteful. Move it to module level so it runs once at startup:

javascript
1// ❌ Reads from disk on every request
2export async function GET() {
3 const font = await fs.readFile('./public/Inter.ttf');
4}
5
6// βœ… Reads once at startup, reused forever
7const fontPromise = fs.readFile('./public/Inter.ttf');
8
9export async function GET() {
10 const font = await fontPromise;
11}
UTF-8 11 Lines

Minimise data crossing the Server/Client boundary

Every prop passed from a Server Component to a Client Component gets serialised into the HTML. Only send what the client actually uses:

javascript
1// ❌ Entire user object serialised β€” all fields added to page weight
2<ClientComponent user={user} />
3
4// βœ… Only the fields this component renders
5<ClientComponent
6 userId={user.id}
7 userName={user.name}
8 userAvatar={user.avatarUrl}
9/>
UTF-8 9 Lines

Use after() for non-blocking work

javascript
1import { after } from 'next/server';
2
3export async function POST(req) {
4 const data = await req.json();
5 const result = await saveToDatabase(data);
6
7 // Runs after the response is sent β€” doesn't block the user
8 after(async () => {
9 await sendAnalyticsEvent('form_submitted', data);
10 await updateSearchIndex(result.id);
11 });
12
13 return Response.json(result);
14}
UTF-8 14 Lines

🟑 4. Re-render Optimization (MEDIUM)

Understand when React re-renders

React re-renders a component whenever:

  • Its state changes
  • Its props change (by reference, not value)
  • Its parent re-renders β€” even if props haven't changed
  • Context it consumes changes

React.memo β€” skip re-renders when props haven't changed

javascript
1const UserCard = React.memo(function UserCard({ user }) {
2 return (
3 <div>
4 <h2>{user.name}</h2>
5 <p>{user.email}</p>
6 </div>
7 );
8});
UTF-8 8 Lines

⚠️ React.memo does a shallow comparison. Passing new object/array literals each render defeats it β€” stabilise references with useMemo or useCallback.

Hoist default non-primitive props

javascript
1// ❌ New array created each render β†’ breaks memoization
2function Component({ items = [] }) { ... }
3
4// βœ… Same reference every time
5const DEFAULT_ITEMS = [];
6function Component({ items = DEFAULT_ITEMS }) { ... }
UTF-8 6 Lines

useCallback β€” stable function references

javascript
1function Parent({ userId }) {
2 const handleDelete = useCallback(() => {
3 deleteUser(userId);
4 }, [userId]);
5
6 return <MemoizedChild onDelete={handleDelete} />;
7}
UTF-8 7 Lines

useMemo β€” cache expensive computations

javascript
1function ProductList({ products, searchTerm }) {
2 const filtered = useMemo(
3 () => products.filter(p =>
4 p.name.toLowerCase().includes(searchTerm.toLowerCase())
5 ),
6 [products, searchTerm]
7 );
8
9 return filtered.map(p => <ProductCard key={p.id} product={p} />);
10}
UTF-8 10 Lines

startTransition β€” defer non-urgent updates

javascript
1import { startTransition } from 'react';
2
3function handleSearch(e) {
4 setQuery(e.target.value); // Urgent: instant input update
5
6 startTransition(() => {
7 setResults(search(e.target.value)); // Non-urgent: defer heavy work
8 });
9}
UTF-8 9 Lines

Never define components inside components

javascript
1// ❌ Row is a brand-new function on every render
2function List({ items }) {
3 function Row({ item }) {
4 return <li>{item.name}</li>;
5 }
6 return <ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>;
7}
8
9// βœ… Defined once outside β€” stable identity
10function Row({ item }) {
11 return <li>{item.name}</li>;
12}
13function List({ items }) {
14 return <ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>;
15}
UTF-8 15 Lines

Split Contexts to limit re-render scope

javascript
1// ❌ Any change to any value re-renders every consumer
2const AppContext = createContext({ user, theme, cart });
3
4// βœ… Each context only causes its own consumers to re-render
5const UserContext = createContext(null);
6const ThemeContext = createContext(null);
7
8function UserProvider({ children }) {
9 const [user, setUser] = useState(null);
10 const value = useMemo(() => ({ user, setUser }), [user]);
11 return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
12}
UTF-8 12 Lines

🟑 5. Rendering & Assets (MEDIUM)

Virtualise long lists

javascript
1import { useVirtualizer } from '@tanstack/react-virtual';
2
3function VirtualList({ items }) {
4 const parentRef = useRef(null);
5 const virtualizer = useVirtualizer({
6 count: items.length,
7 getScrollElement: () => parentRef.current,
8 estimateSize: () => 60,
9 });
10
11 return (
12 <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
13 <div style={{ height: virtualizer.getTotalSize() }}>
14 {virtualizer.getVirtualItems().map(row => (
15 <div
16 key={row.index}
17 style={{ position: 'absolute', top: row.start, height: row.size }}
18 >
19 {items[row.index].name}
20 </div>
21 ))}
22 </div>
23 </div>
24 );
25}
UTF-8 25 Lines

Use content-visibility for off-screen sections

css
1.page-section {
2 content-visibility: auto;
3 contain-intrinsic-size: 0 300px;
4}
UTF-8 4 Lines

Use safe conditionals β€” avoid && with non-booleans

javascript
1// ❌ Renders the string "0" to the DOM when count is 0
2{count && <Component />}
3
4// βœ… Correct
5{count > 0 && <Component />}
6{count ? <Component /> : null}
UTF-8 6 Lines

Optimise images with next/image

javascript
1import Image from 'next/image';
2
3<Image
4 src="/hero.jpg"
5 alt="Hero"
6 width={1200}
7 height={600}
8 priority
9 placeholder="blur"
10/>
UTF-8 10 Lines

Add resource hints for critical assets

javascript
1import { preload, preconnect, prefetchDNS } from 'react-dom';
2
3preload('/fonts/inter.woff2', { as: 'font', crossOrigin: 'anonymous' });
4preconnect('https://api.example.com');
5prefetchDNS('https://cdn.example.com');
UTF-8 5 Lines

Use passive listeners for scroll

javascript
1// ❌ Browser waits on every scroll to see if you call preventDefault
2window.addEventListener('scroll', handler);
3
4// βœ… Browser scrolls freely β€” much smoother
5window.addEventListener('scroll', handler, { passive: true });
UTF-8 5 Lines

🟒 6. JavaScript Micro-Optimisations (LOW-MEDIUM)

Use Map for repeated lookups

javascript
1// ❌ O(n) on every lookup
2const user = users.find(u => u.id === id);
3
4// βœ… Build index once, O(1) forever
5const userMap = new Map(users.map(u => [u.id, u]));
6const user = userMap.get(id);
UTF-8 6 Lines

Cache localStorage reads

javascript
1let cachedTheme = localStorage.getItem('theme');
2
3function getTheme() { return cachedTheme; }
4function setTheme(t) {
5 cachedTheme = t;
6 localStorage.setItem('theme', t);
7}
UTF-8 7 Lines

Defer non-critical work with requestIdleCallback

javascript
1requestIdleCallback(() => {
2 analytics.track('page_view', { path: window.location.pathname });
3}, { timeout: 2000 });
UTF-8 3 Lines

Use flatMap instead of separate filter + map

javascript
1// ❌ Two passes over the array
2const result = items
3 .filter(item => item.active)
4 .map(item => item.value);
5
6// βœ… One pass
7const result = items.flatMap(item => item.active ? [item.value] : []);
UTF-8 7 Lines

Measuring: Profile Before You Optimise

  • React DevTools Profiler β€” see which components render, how often, and why
  • Chrome DevTools Performance tab β€” full flame chart of JS execution
  • Lighthouse β€” page-level score with specific recommendations
  • <Profiler>** API** β€” add programmatic measurements to critical paths
javascript
1import { Profiler } from 'react';
2
3<Profiler id="Dashboard" onRender={(id, phase, duration) => {
4 console.log(`${id} [${phase}]: ${duration.toFixed(2)}ms`);
5}}>
6 <Dashboard />
7</Profiler>
UTF-8 7 Lines

Quick-Start Checklist

  • βœ… Replace sequential await chains with Promise.all()
  • βœ… Add <Suspense> boundaries around independently-fetched sections
  • βœ… Avoid barrel imports β€” import files directly
  • βœ… Use next/dynamic for heavy components
  • βœ… Wrap cache() around repeated server-side data fetches
  • βœ… Only pass necessary props across the Server/Client boundary
  • βœ… Use React.memo + stable references for expensive components
  • βœ… Never define components inside other components
  • βœ… Virtualise any list over ~100 items
  • βœ… Always measure before and after β€” intuition is often wrong