What you'll learn
By the end of this you'll know how to spot performance problems before guessing at solutions, which React-specific patterns cause unnecessary re-renders and how to fix them, how keys affect list reconciliation, how images slow pages down in ways that aren't obvious, and how code splitting in the App Router can shrink your initial bundle without much effort. And you'll have a checklist you can run through on any project.
The goal isn't micro-optimisation for its own sake. It's shipping pages that feel fast without spending a week on it.
Who this is for
- You're building a React or Next.js app and it feels sluggish but you're not sure what to fix first
- You've seen advice like "memoize everything" or "avoid re-renders at all costs" and you want to understand when it actually matters
- You're comfortable with React basics and you want to move from "it works" to "it's fast"
You can skip this if you're dealing with a specific bottleneck you've already profiled. Find the matching section and skip to it — the checklist at the end still applies.
What is a re-render?
A re-render is React calling your component function again to produce updated UI. It's not inherently bad — React is designed around it. But unnecessary re-renders on large component trees, or on components that are expensive to render, add up.
Plain English: every time state changes, React figures out what the UI should look like now. That process is fast. But doing it for thousands of components that didn't change, or triggering it too often, wastes time the browser could spend on something else.
Simple idea: the goal isn't zero re-renders. It's making sure the right components re-render when they need to, and only then.
Prerequisites
- You're working with React 18+ (Next.js App Router experience helps for the later sections)
- You know what
useState,useEffect, and props are - Basic familiarity with browser DevTools (Network and Performance tabs)
Key terms
Re-render — React calling a component function again to produce new output. Triggered by state changes, prop changes, or a parent re-render.
Reconciliation — React's process of comparing the previous and new virtual DOM to decide which real DOM nodes need updating.
Key — a prop React uses to track list items across renders. Wrong or missing keys cause reconciliation bugs and unnecessary DOM work.
Code splitting — breaking your JS bundle into smaller chunks loaded on demand instead of shipping everything upfront.
Dynamic import — the import() function that triggers code splitting. Next.js wraps it in next/dynamic.
LCP (Largest Contentful Paint) — the Web Vitals metric for how quickly the main visible content appears. Usually an image or a large block of text.
Setup from zero
Step 1 — Open React DevTools Profiler
Install React DevTools from the browser extension store if you haven't. Open DevTools, go to the Profiler tab, click Record, interact with your page for a few seconds, then stop. You'll see a flame chart showing which components rendered and how long each took.
This is the tool. Run it before changing a single line of code.
Step 2 — Enable "Highlight updates"
In React DevTools, click the gear icon and turn on "Highlight updates when components render." Every component that re-renders flashes on screen. Click around and watch for things flashing when they shouldn't be.
Step 3 — Check the Network tab
Large bundles, unoptimised images, and waterfall requests all show up here. Sort by file size and look for anything over 100 kB loading on the initial page. Those are your candidates for code splitting or replacement.
The mental model
Measure, then fix. This sounds obvious but it's ignored constantly. Performance advice is full of "always memoize callbacks" and "never use index as a key" — some of it's right, most of it depends on context. The only way to know what's actually slow is to profile it. Measure first, fix what the profiler shows you, measure again to confirm it helped.
Step-by-step
Finding unnecessary re-renders with Profiler
The flame chart shows each commit — each time React updated the DOM. Grey components didn't re-render that commit; coloured ones did. Tall coloured bars mean expensive renders.
A common culprit: passing a new object or array literal as a prop on every render.
// New array on every render — PostList always re-renders
<PostList filters={["published", "featured"]} />
// Fix: stable reference at module scope
const DEFAULT_FILTERS = ["published", "featured"];
<PostList filters={DEFAULT_FILTERS} />
Keys in lists
// Bad: index as key — causes wrong re-renders when list order changes
posts.map((p, i) => <PostCard key={i} post={p} />);
// Good: stable unique ID from your data
posts.map((p) => <PostCard key={p.id} post={p} />);
Index keys tell React "this is item number 3" rather than "this is the post about TypeScript." When the list reorders or an item is removed from the middle, React reuses the wrong DOM node. Use a stable unique ID from your data.
Little tip: if your documents come from MongoDB, _id is already a stable unique ID. Use p._id.toString() as the key rather than adding a separate field just for this.
Images and LCP
import Image from "next/image";
{/* LCP image — priority preloads it */}
<Image
src="/hero.jpg"
alt="Descriptive alt text here"
width={1200}
height={630}
priority
/>
{/* Below-fold images — lazy load by default, no priority */}
<Image
src="/thumbnail.jpg"
alt="Post thumbnail"
width={400}
height={300}
/>
One priority prop per page maximum. It tells the browser to preload the image because it's the LCP element. Adding priority to multiple images defeats the purpose — the browser can't prioritise everything.
Working examples
Code splitting with next/dynamic
import dynamic from "next/dynamic";
const RichEditor = dynamic(() => import("@/components/rich-editor"), {
loading: () => <p>Loading editor...</p>,
ssr: false, // skip server render for browser-only components
});
export default function EditPage() {
return (
<main>
<h1>Edit post</h1>
<RichEditor />
</main>
);
}
The editor bundle only loads when this page is visited — not on every page. This is the right pattern for any large interactive component that doesn't need to be in the main bundle: chart libraries, date pickers, map widgets, PDF viewers.
Server Components as a performance tool
If you're on the App Router, the most direct performance improvement is often not a React optimisation at all — it's moving data fetching into a Server Component.
// app/blog/page.tsx — Server Component, no client bundle contribution
export default async function BlogPage() {
const posts = await db.posts.findAll({ status: "published" });
return <PostGrid posts={posts} />;
}
Compare this to a Client Component that fetches with useEffect: the user sees a blank page or spinner first, then the data. With a Server Component, the first paint already has the content. No client-side fetch, no loading state, no extra JavaScript. The related posts on RSC and the App Router go deeper on this — the patterns there directly affect your Lighthouse scores.
Little tip: you don't have to rewrite everything at once. Start with the highest-traffic page and move its data fetch to a Server Component. Run Lighthouse before and after. The LCP improvement is usually visible.
Patterns / when to use
Use React.memo when — a child component re-renders frequently even though its props don't change, and the render is visually expensive. Measure first. Many components are cheap enough that the memoization overhead costs more than it saves.
Code split when — a component or library is large (check the bundle analyser) and only needed on a specific route or user interaction. Common candidates: rich text editors, chart libraries, admin panels, anything behind a button click.
Prefer Server Components for data — any component that doesn't need browser APIs or client state is a Server Component candidate. Smaller client bundle, faster first paint, no loading states for the initial render.
Common mistakes
Memoizing everything preemptively — wrapping every function in useCallback and every computed value in useMemo adds overhead and makes the code harder to follow. Profile first, memoize where the profiler shows it helps.
Inline function props breaking React.memo
// New function reference every render — React.memo on Button is useless
<Button onClick={() => doThing(id)} />
// Stable reference with useCallback (only matters if Button uses React.memo)
const handleClick = useCallback(() => doThing(id), [id]);
<Button onClick={handleClick} />
useCallback on a handler only matters if the component receiving it is wrapped in React.memo. Without that, the stable reference doesn't prevent re-renders.
Images without dimensions — next/image needs width and height (or the fill prop with a sized parent) to reserve space in the layout. Skip them and you get layout shift — elements jumping around as images load, which tanks your CLS score and annoys users.
Loading heavy libraries unconditionally — chart libraries, PDF renderers, and code editors can add hundreds of kilobytes to your main bundle. Import them with next/dynamic so they only load when the relevant page or component mounts.
Troubleshooting
Lighthouse Performance score is low — run the mobile throttled audit, not just desktop. Look at which metrics are failing: LCP (image or content slow), CLS (layout shift from images or fonts), INP (interaction delay from heavy scripts).
Component re-renders on every parent render — it's not wrapped in React.memo, or it's receiving a new object, array, or function prop each render. Profile first to confirm it's actually a problem.
Large initial JS bundle — run @next/bundle-analyzer to see what's biggest. Most large bundles have one or two outsized dependencies that can be dynamically imported or replaced.
Images cause layout shift — width and height are missing from your <Image> tags, or you're using a raw <img> tag. Always provide dimensions or use fill with a sized parent container.
Checklist
- [ ] React DevTools Profiler run before any optimisation attempt
- [ ] "Highlight updates" used to identify components re-rendering unexpectedly
- [ ] List items use stable unique IDs as keys (not array indexes)
- [ ] LCP image uses
<Image priority>(one per page maximum) - [ ] All other images use
<Image>with explicitwidthandheight - [ ] Heavy interactive components are code-split with
next/dynamic - [ ] Data-fetching components are Server Components where possible
- [ ] Object, array, and function props are stable references (not inline literals)
- [ ]
React.memoadded only where Profiler confirms it helps - [ ]
@next/bundle-analyzerrun to identify large dependencies
Practice task
Take a Next.js page that fetches posts with useEffect and shows a loading spinner. Rewrite it as a Server Component that fetches data directly. Run Lighthouse before and after and note the LCP change. Then load the largest component on that page via next/dynamic and check the Network tab to confirm it arrives in a separate chunk.
FAQ
Should I use React.memo on every component?
No. Wrap components in React.memo after you've confirmed with the Profiler that they re-render unnecessarily and the re-render is causing visible problems. Most components are fast enough that memoization adds more cost than it removes.
Does the React Compiler mean I don't need to think about performance?
It helps with automatic memoization, but it doesn't fix architectural problems — fetching data on the client when you could fetch it on the server, loading a 500 kB library unconditionally, or missing priority on the LCP image. The fundamentals still matter.
How do I measure performance in production?
Lighthouse gives you a lab measurement in DevTools. For real user data you need something like Vercel Analytics, Datadog RUM, or a similar tool that collects Web Vitals from actual visitors.
What are the fastest wins for Lighthouse scores?
Moving the page's primary data fetch to a Server Component, adding priority to the LCP image, and code-splitting the largest interactive component. Those three together usually move the needle more than any amount of memoization.
What to learn next
- React hooks patterns — the companion post covers hook patterns that directly affect re-render correctness
- React Server Components — the server/client split is where the biggest performance wins live in the App Router
- Web Vitals — understanding LCP, CLS, and INP helps you prioritise which fixes matter for real users
Related on Baseline
- [React hooks patterns in 2026](/developers/react/react-hooks-patterns-2026)
- [React Server Components explained](/developers/react/react-server-components-explained)
- [Next.js App Router guide](/developers/nextjs/nextjs-app-router-guide)
Takeaways
Performance work starts with measurement. React DevTools Profiler shows you what's actually slow — open it before touching a single line of code. The biggest wins are usually architectural: move data fetching to Server Components, code-split heavy interactive pieces, get your LCP image right. Memoization is a scalpel, not a paint roller; use it where the profiler points.
If you remember only one thing: profile first, fix the thing the profiler shows you, measure again. That loop is more valuable than any checklist, including this one.