React performance, measured and optimized.
Why every millisecond counts, how React actually renders, and the techniques that move the numbers, from memoization and concurrency to code splitting. A field guide, not a cheat sheet.
Performance is a business metric
Speed maps directly to revenue, engagement and retention. The numbers are unambiguous.
revenue lost per 100ms of latency
traffic drop from a 500ms delay
more signups from 40% faster loads
users lost per additional second
conversion lift per 1s improvement
| Metric | Measures | Target | React impact |
|---|---|---|---|
| LCP | Largest Contentful Paint | < 2.5s | Bundle size, hydration |
| INP | Input responsiveness | < 100ms | Blocking renders |
| CLS | Layout stability | < 0.1 | Dynamic content |
| FCP | First Contentful Paint | < 1.8s | Initial bundle |
| TTI | Time to Interactive | < 3.8s | Hydration time |
| TTFB | Server response | < 800ms | SSR performance |
The React rendering pipeline
Rendering happens in three phases. Understanding them is the prerequisite to optimizing them.
The Render phase builds the Virtual DOM and reconciles, and in React 18 it’s interruptible. The Commit phase applies DOM mutations and runs effects synchronously. Fiber is what makes the render phase pausable: it breaks work into chunks, prioritizes by importance, and aborts low-priority work.
| Priority | Timeout | Use case |
|---|---|---|
| Immediate | Sync | Critical errors, text input |
| User blocking | 250ms | Clicks, interactions |
| Normal | 5s | Network responses |
| Low | 10s | Background updates |
| Idle | Never | Prefetching |
Concurrent rendering
A fundamental shift: interruptible rendering and prioritized updates keep the app responsive even under heavy work, with up to ~60% better perceived performance for complex apps.
Automatic batching
Multiple state updates are grouped into a single re-render, even in timeouts and promises.
Transitions
Mark non-urgent updates so React prioritizes user input over heavy work.
Suspense
Declarative async loading boundaries with fallbacks.
Streaming SSR
Progressive HTML: send the shell first, hydrate as data resolves.
↑ user can interact between every chunk
1// React 18: both updates are batched into a single re-render2setTimeout(() => {3 setCount(c => c + 1);4 setFlag(f => !f);5}, 1000);1import { startTransition } from 'react';2 3// Urgent: show what the user typed4setInputValue(input);5 6// Non-urgent: update search results (interruptible)7startTransition(() => {8 setSearchQuery(input);9});1<Suspense fallback={<Spinner />}>2 <UserProfile /> {/* can suspend while loading data */}3</Suspense>Where React apps slow down
Five patterns account for most real-world React jank. Each has a clear fix, and measurable impact.
Unnecessary re-renders
When a parent re-renders, all children re-render by default, even with unchanged props. React.memo short-circuits that with a shallow prop comparison.
1// Solution: memoize so it skips re-render when props are unchanged2const ExpensiveChild = React.memo(() => {3 // expensive calculations4 return <div>Content</div>;5});Inline functions and objects
Creating new function or object references in render breaks memoization, so children see “new” props every time. useCallback and useMemo keep references stable.
1// useCallback keeps the function reference stable;2// useMemo keeps the object reference stable.3function Parent() {4 const handleClick = useCallback(() => console.log('clicked'), []);5 const style = useMemo(() => ({ color: 'red' }), []);6 return <MemoizedChild onClick={handleClick} style={style} />;7}Context overuse
Every consumer re-renders when any context value changes. Split contexts by concern, or reach for a store like Zustand for fine-grained subscriptions.
Large lists without virtualization
Rendering thousands of DOM nodes when only ~20 are visible. Virtualization renders only what’s on screen.
Expensive computations in render
Filtering and sorting on every render is wasteful for large datasets. useMemo caches the result until inputs change.
1// Cache filtered + sorted results; recompute only when inputs change2function ProductList({ products, filter }) {3 const processed = useMemo(() => (4 products5 .filter(p => p.name.includes(filter))6 .sort((a, b) => b.price - a.price)7 ), [products, filter]);8 9 return processed.map(p => <ProductCard key={p.id} product={p} />);10}The memoization toolkit
Three tools, each with a clear job, and a clear cost if misused.
1const expensiveResult = useMemo(() => {2 return performExpensiveCalculation(data);3}, [data]); // recomputes only when `data` changes1// New function every render → MemoizedChild re-renders every time2function Parent() {3 const handleClick = () => console.log('Button clicked');4 return <MemoizedChild onClick={handleClick} />;5}1// Stable reference → MemoizedChild renders once2function Parent() {3 const handleClick = useCallback(() => {4 console.log('Button clicked');5 }, []); // never changes6 return <MemoizedChild onClick={handleClick} />;7}- Passing callbacks to memoized children
- A value feeds another hook’s dependency array
- A component is genuinely expensive and the parent re-renders often
- The component is simple and fast
- Props change on nearly every render anyway
- You’re optimizing before measuring
Seven ways to make it worse
The most common mistakes we see in review, each shown as the problem, then the fix.
Premature optimization
Profile first. Memoization adds complexity and overhead, so apply it to measured bottlenecks only.
1function Simple({ name }) {2 const greeting = useMemo(() => `Hello, ${name}`, [name]); // unnecessary3 const onClick = useCallback(() => log(name), [name]); // unnecessary4 return <div onClick={onClick}>{greeting}</div>;5}1function Simple({ name }) {2 return <div onClick={() => log(name)}>Hello, {name}</div>;3}Mutating props or state
Same reference → React can’t detect the change. Always create new objects/arrays.
1const addTodo = (text) => {2 todos.push({ id: Date.now(), text }); // mutates3 setTodos(todos); // same ref, no re-render4};1const addTodo = (text) => {2 setTodos([...todos, { id: Date.now(), text }]); // new array3};Incorrect dependency arrays
Missing deps capture stale values and cause subtle, hard-to-reproduce bugs.
1const fetchResults = useCallback(() => {2 api.search(query).then(setResults); // uses query…3}, []); // …but it's missing from deps → stale closure1const fetchResults = useCallback(() => {2 api.search(query).then(setResults);3}, [query]); // correct dependencyComponents defined inside render
A new identity each render unmounts and remounts, destroying state and DOM.
1function Parent() {2 // new component identity every render → unmount/remount3 function Child() { return <input />; }4 return <Child />; // loses input state on every Parent render5}1function Child() { return <input />; }2function Parent() {3 return <Child />; // stable identity, keeps state4}Index as key in dynamic lists
Indices misidentify elements on reorder/insert, so state lands on the wrong row.
1{todos.map((todo, index) => (2 <TodoItem key={index} todo={todo} /> // breaks on reorder/insert3))}1{todos.map((todo) => (2 <TodoItem key={todo.id} todo={todo} /> // stable identity3))}Not cleaning up effects
Subscriptions and timers pile up, causing memory leaks and eventual crashes.
1useEffect(() => {2 const id = setInterval(tick, 1000);3 // no cleanup, interval leaks after unmount4}, []);1useEffect(() => {2 const id = setInterval(tick, 1000);3 return () => clearInterval(id); // cleanup on unmount4}, []);Large context values
Monolithic context re-renders every consumer. Split by concern.
1// One monolithic context, any change re-renders every consumer2const value = { user, setUser, theme, setTheme, settings, cart, setCart };1// Split by concern: consumers re-render only on their slice2const UserContext = createContext();3const ThemeContext = createContext();4const CartContext = createContext();Keeping input responsive under load
useTransition
Mark expensive updates as non-urgent so React prioritizes user input. While the transition is pending, isPending lets you show a loading state.
1const [isPending, startTransition] = useTransition();2 3const handleSearch = (value) => {4 setQuery(value); // urgent, immediate5 startTransition(() => {6 setResults(filterData(value)); // non-urgent, interruptible7 });8};useDeferredValue
Returns a deferred copy of a fast-changing value. React renders with the old value first, then schedules a low-priority render with the new one, keeping typing snappy.
1function SearchPage() {2 const [query, setQuery] = useState('');3 const deferredQuery = useDeferredValue(query);4 5 return (6 <>7 <input value={query} onChange={(e) => setQuery(e.target.value)} />8 <ExpensiveList query={deferredQuery} /> {/* renders deferred */}9 </>10 );11}Ship less JavaScript up front
Loading everything on first paint inflates the bundle and Time to Interactive. lazy() + Suspense load code on demand.
1import { lazy, Suspense } from 'react';2 3// Only downloaded when the route is accessed4const Dashboard = lazy(() => import('./Dashboard'));5const Settings = lazy(() => import('./Settings'));6 7<Suspense fallback={<Spinner />}>8 <Dashboard />9</Suspense>Measure, then optimize
React performance is an ongoing practice, not a one-time task. Combine modern features (useTransition, useDeferredValue) with proven strategies like memoization, code splitting and correct keys, and you get apps that are fast and scalable.
- Measure before optimizing: Profiler and browser tools first.
- Avoid premature optimization; target real bottlenecks.
- Memoize strategically; split your code; clean up effects.
- Split contexts; never use array index as a key for dynamic lists.