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.
| Priority | Topic |
| π΄ CRITICAL | Eliminating Data Waterfalls |
| π΄ CRITICAL | Bundle Size |
| π HIGH | Server-Side Performance |
| π‘ MEDIUM | Re-render Optimization |
| π‘ MEDIUM | Rendering & Assets |
| π’ LOW-MEDIUM | JavaScript 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
| 1 | |
| 2 | async function Dashboard() { |
| 3 | const user = await fetchUser(); |
| 4 | const posts = await fetchPosts(); |
| 5 | const stats = await fetchStats(); |
| 6 | } |
| 7 | |
| 8 | |
| 9 | async function Dashboard() { |
| 10 | const [user, posts, stats] = await Promise.all([ |
| 11 | fetchUser(), |
| 12 | fetchPosts(), |
| 13 | fetchStats(), |
| 14 | ]); |
| 15 | } |
Don't await what you might not need
| 1 | |
| 2 | async function handler() { |
| 3 | const flag = await getFeatureFlag('new-ui'); |
| 4 | const data = await fetchData(); |
| 5 | if (flag) return renderNewUI(data); |
| 6 | return renderOldUI(); |
| 7 | } |
| 8 | |
| 9 | |
| 10 | async 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 | } |
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:
| 1 | export 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 | } |
π΄ 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:
| 1 | |
| 2 | import { Button } from '@/components'; |
| 3 | |
| 4 | |
| 5 | import { Button } from '@/components/Button'; |
| 6 | |
| 7 | |
| 8 | import { debounce } from 'lodash'; |
| 9 | |
| 10 | |
| 11 | import debounce from 'lodash/debounce'; |
Lazy-load heavy components with next/dynamic
| 1 | import dynamic from 'next/dynamic'; |
| 2 | |
| 3 | |
| 4 | const HeavyChart = dynamic(() => import('@/components/Chart'), { |
| 5 | loading: () => <p>Loading...</p>, |
| 6 | ssr: false, |
| 7 | }); |
| 8 | |
| 9 | |
| 10 | const Dashboard = lazy(() => import('./pages/Dashboard')); |
Prefetch on hover for perceived speed
| 1 | function 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 | } |
π 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:
| 1 | import { cache } from 'react'; |
| 2 | |
| 3 | export const getUser = cache(async (id: string) => { |
| 4 | return db.user.findUnique({ where: { id } }); |
| 5 | }); |
| 6 | |
| 7 | |
| 8 | |
| 9 | async function ProfileHeader({ userId }) { |
| 10 | const user = await getUser(userId); |
| 11 | return <h1>{user.name}</h1>; |
| 12 | } |
| 13 | |
| 14 | async function ProfileStats({ userId }) { |
| 15 | const user = await getUser(userId); |
| 16 | return <p>{user.postsCount} posts</p>; |
| 17 | } |
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:
| 1 | |
| 2 | export async function GET() { |
| 3 | const font = await fs.readFile('./public/Inter.ttf'); |
| 4 | } |
| 5 | |
| 6 | |
| 7 | const fontPromise = fs.readFile('./public/Inter.ttf'); |
| 8 | |
| 9 | export async function GET() { |
| 10 | const font = await fontPromise; |
| 11 | } |
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:
| 1 | |
| 2 | <ClientComponent user={user} /> |
| 3 | |
| 4 | |
| 5 | <ClientComponent |
| 6 | userId={user.id} |
| 7 | userName={user.name} |
| 8 | userAvatar={user.avatarUrl} |
| 9 | /> |
Use after() for non-blocking work
| 1 | import { after } from 'next/server'; |
| 2 | |
| 3 | export async function POST(req) { |
| 4 | const data = await req.json(); |
| 5 | const result = await saveToDatabase(data); |
| 6 | |
| 7 | |
| 8 | after(async () => { |
| 9 | await sendAnalyticsEvent('form_submitted', data); |
| 10 | await updateSearchIndex(result.id); |
| 11 | }); |
| 12 | |
| 13 | return Response.json(result); |
| 14 | } |
π‘ 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
| 1 | const UserCard = React.memo(function UserCard({ user }) { |
| 2 | return ( |
| 3 | <div> |
| 4 | <h2>{user.name}</h2> |
| 5 | <p>{user.email}</p> |
| 6 | </div> |
| 7 | ); |
| 8 | }); |
β οΈ 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
| 1 | |
| 2 | function Component({ items = [] }) { ... } |
| 3 | |
| 4 | |
| 5 | const DEFAULT_ITEMS = []; |
| 6 | function Component({ items = DEFAULT_ITEMS }) { ... } |
useCallback β stable function references
| 1 | function Parent({ userId }) { |
| 2 | const handleDelete = useCallback(() => { |
| 3 | deleteUser(userId); |
| 4 | }, [userId]); |
| 5 | |
| 6 | return <MemoizedChild onDelete={handleDelete} />; |
| 7 | } |
useMemo β cache expensive computations
| 1 | function 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 | } |
startTransition β defer non-urgent updates
| 1 | import { startTransition } from 'react'; |
| 2 | |
| 3 | function handleSearch(e) { |
| 4 | setQuery(e.target.value); |
| 5 | |
| 6 | startTransition(() => { |
| 7 | setResults(search(e.target.value)); |
| 8 | }); |
| 9 | } |
Never define components inside components
| 1 | |
| 2 | function 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 | |
| 10 | function Row({ item }) { |
| 11 | return <li>{item.name}</li>; |
| 12 | } |
| 13 | function List({ items }) { |
| 14 | return <ul>{items.map(i => <Row key={i.id} item={i} />)}</ul>; |
| 15 | } |
Split Contexts to limit re-render scope
| 1 | |
| 2 | const AppContext = createContext({ user, theme, cart }); |
| 3 | |
| 4 | |
| 5 | const UserContext = createContext(null); |
| 6 | const ThemeContext = createContext(null); |
| 7 | |
| 8 | function 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 | } |
π‘ 5. Rendering & Assets (MEDIUM)
Virtualise long lists
| 1 | import { useVirtualizer } from '@tanstack/react-virtual'; |
| 2 | |
| 3 | function 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 | } |
Use content-visibility for off-screen sections
| 1 | .page-section { |
| 2 | content-visibility: auto; |
| 3 | contain-intrinsic-size: 0 300px; |
| 4 | } |
Use safe conditionals β avoid && with non-booleans
| 1 | |
| 2 | {count && <Component />} |
| 3 | |
| 4 | |
| 5 | {count > 0 && <Component />} |
| 6 | {count ? <Component /> : null} |
Optimise images with next/image
| 1 | import 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 | /> |
Add resource hints for critical assets
| 1 | import { preload, preconnect, prefetchDNS } from 'react-dom'; |
| 2 | |
| 3 | preload('/fonts/inter.woff2', { as: 'font', crossOrigin: 'anonymous' }); |
| 4 | preconnect('https://api.example.com'); |
| 5 | prefetchDNS('https://cdn.example.com'); |
Use passive listeners for scroll
| 1 | |
| 2 | window.addEventListener('scroll', handler); |
| 3 | |
| 4 | |
| 5 | window.addEventListener('scroll', handler, { passive: true }); |
π’ 6. JavaScript Micro-Optimisations (LOW-MEDIUM)
Use Map for repeated lookups
| 1 | |
| 2 | const user = users.find(u => u.id === id); |
| 3 | |
| 4 | |
| 5 | const userMap = new Map(users.map(u => [u.id, u])); |
| 6 | const user = userMap.get(id); |
Cache localStorage reads
| 1 | let cachedTheme = localStorage.getItem('theme'); |
| 2 | |
| 3 | function getTheme() { return cachedTheme; } |
| 4 | function setTheme(t) { |
| 5 | cachedTheme = t; |
| 6 | localStorage.setItem('theme', t); |
| 7 | } |
Defer non-critical work with requestIdleCallback
| 1 | requestIdleCallback(() => { |
| 2 | analytics.track('page_view', { path: window.location.pathname }); |
| 3 | }, { timeout: 2000 }); |
Use flatMap instead of separate filter + map
| 1 | |
| 2 | const result = items |
| 3 | .filter(item => item.active) |
| 4 | .map(item => item.value); |
| 5 | |
| 6 | |
| 7 | const result = items.flatMap(item => item.active ? [item.value] : []); |
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
| 1 | import { 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> |
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