React Performance Optimization Techniques for 2026
Advanced techniques for optimizing React applications, including React Server Components, streaming SSR, and the latest performance patterns from the React team.
React performance advice ages badly. A good share of what was standard practice three years ago is now either handled automatically or actively counterproductive, and teams are still carrying the habits.
Here is what matters in 2026, and what has quietly stopped mattering.
Measure the right thing first
Almost every React performance problem we are called in to look at falls into one of two categories, and they need opposite fixes.
| Symptom | Category | Where to look |
|---|---|---|
| Slow first load, blank screen, poor LCP | Delivery | Bundle size, server rendering, network waterfalls |
| Sluggish typing, janky scroll, slow interactions | Runtime | Re-renders, expensive work on the main thread |
Teams routinely apply runtime fixes to a delivery problem. Memoising components does nothing for a user staring at a white screen while 900KB of JavaScript downloads. Establish which category you are in before touching anything.
And measure on real traffic rather than a developer machine. A fast laptop on office wifi hides essentially everything a mobile visitor experiences.
Delivery: the biggest lever is not sending the JavaScript
React Server Components are now the default in the Next.js App Router, and the reason they matter is simple: a Server Component's code never reaches the browser at all. Not deferred, not lazily loaded - never sent.
That makes the useful mental model a boundary question rather than a component question. Everything is server-rendered by default, and you push the client boundary as far down the tree as it will go.
// Anti-pattern: the boundary is at the top, so the whole subtree ships.
'use client'
export default function ProductPage({ product }) {
const [qty, setQty] = useState(1)
return (
<>
<ProductDetails product={product} /> {/* static, still shipped */}
<Reviews reviews={product.reviews} /> {/* static, still shipped */}
<QuantityPicker value={qty} onChange={setQty} />
</>
)
}
// Better: only the interactive leaf is a Client Component.
export default function ProductPage({ product }) {
return (
<>
<ProductDetails product={product} />
<Reviews reviews={product.reviews} />
<QuantityPicker /> {/* 'use client' lives in this file only */}
</>
)
}Moving a single 'use client' directive down two levels routinely removes more bundle weight than a week of micro-optimisation.
Traditional server rendering waits for every data dependency before sending a byte. One slow query holds the entire page, and your LCP is hostage to your slowest call.
Streaming with Suspense boundaries sends the shell immediately and fills sections in as their data resolves. The practical guidance is to wrap anything slow, independent and below the fold - recommendations, reviews, activity feeds - and leave the primary content unwrapped so it arrives in the first flush.
A caution worth stating: a Suspense boundary around the main content moves your LCP element out of the initial HTML and can make the measured score worse. Stream the periphery, not the point of the page.
A client-rendered single-page application serves an empty container and builds the page in the browser. For an application behind a login that is fine. For anything that needs to be found, it is decisive: crawlers that do not execute JavaScript - including several of the ones behind AI answer engines - receive no content at all.
That is a search problem rather than a performance problem, and it is not fixable with optimisation. It needs server rendering.
Runtime: the compiler changed the advice
The React Compiler applies memoisation automatically at build time, and it does it more accurately than hand-written useMemo and useCallback because it does not depend on someone maintaining a dependency array correctly.
Where it is enabled, manual memoisation is largely obsolete. Existing calls are not harmful, but adding new ones is work with no return, and a stale dependency array is a real bug that the compiler does not have.
- Context that changes often. A context value updating on every keystroke re-renders every consumer, and the compiler cannot help - the value genuinely changed. Split contexts by update frequency, keeping fast-changing state out of one that many components read.
- Large lists. Virtualisation is still the answer above roughly a few hundred rows. Nothing has replaced it.
- Expensive main-thread work. Heavy parsing, large sorts, image processing. Move it to a web worker or the server; memoisation only avoids repeating it.
- Effects that fire more than intended. Still the most common cause of an unexplained performance cliff, and still best found with the Profiler rather than by reading code.
When typing feels laggy, it is usually because each keystroke triggers an expensive render synchronously. Marking the expensive update as a transition lets React keep the input responsive and interrupt the heavy work as new keystrokes arrive.
const [query, setQuery] = useState('')
const [results, setResults] = useState(items)
const [isPending, startTransition] = useTransition()
function onChange(e) {
setQuery(e.target.value) // urgent: the input must keep up
startTransition(() => {
setResults(filter(items, e.target.value)) // interruptible
})
}This is one of the few genuinely new runtime tools, and it targets exactly the complaint users articulate as "the app feels slow".
The metrics you are actually being graded on
Core Web Vitals are measured on real visitors rather than in a lab, and they feed both search ranking and the numbers your stakeholders see. Three matter:
| Metric | What it measures | Usual React cause when it is bad |
|---|---|---|
| LCP | When the largest element finishes rendering | Unoptimised hero image, or content blocked behind a client-side fetch |
| INP | How quickly the page responds to interaction | Expensive synchronous renders on input; long tasks blocking the main thread |
| CLS | How much the layout shifts unexpectedly | Images without dimensions, late-loading fonts, content injected above the fold |
INP replaced First Input Delay and is the one React applications most often fail, because it measures every interaction across the visit rather than just the first. A dashboard that opens quickly and then stutters on every filter change scores badly, and correctly so.
CLS is usually the cheapest to fix and the most commonly ignored. Setting explicit width and height on images, reserving space for anything that loads late, and never inserting a banner above existing content resolves the large majority of it.
The unglamorous wins
Before any of the above, these are usually available and usually larger:
- Images. Modern formats, correct sizing, explicit width and height so nothing shifts, lazy loading below the fold. Images are the LCP element on most pages and the largest share of bytes on nearly all of them.
- Fonts. Self-host, preload the one face used above the fold, and set font-display so text is never invisible. A blocking font request delays every word on the page.
- Third-party scripts. Analytics, chat widgets and tag managers frequently outweigh the entire application bundle. Audit what is loading and defer everything that is not needed for the first interaction.
- Route-level code splitting. Still the highest-value split, and far more effective than component-level splitting.
A short checklist
- Field data before optimisation - real devices, real networks.
- Classify the problem as delivery or runtime before choosing a fix.
- Push the 'use client' boundary as deep as it will go.
- Stream the periphery with Suspense; keep the LCP element in the first flush.
- Enable the compiler and stop adding manual memoisation.
- Virtualise long lists, split hot contexts, move heavy work off the main thread.
- Fix images, fonts and third-party scripts first - they are usually the biggest numbers on the page.
