Skip to content
Zowork
All case studies
Engineering case study~14 min read

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.

ReactPerformanceCore Web VitalsReact 18Concurrency
Why it matters

Performance is a business metric

Speed maps directly to revenue, engagement and retention. The numbers are unambiguous.

1%

revenue lost per 100ms of latency

Amazon
20%

traffic drop from a 500ms delay

Google
15%

more signups from 40% faster loads

Pinterest
10%

users lost per additional second

BBC
2%

conversion lift per 1s improvement

Walmart
Core Web Vitals: Google’s ranking signals since 2021
MetricMeasuresTargetReact impact
LCPLargest Contentful Paint< 2.5sBundle size, hydration
INPInput responsiveness< 100msBlocking renders
CLSLayout stability< 0.1Dynamic content
FCPFirst Contentful Paint< 1.8sInitial bundle
TTITime to Interactive< 3.8sHydration time
TTFBServer response< 800msSSR performance
What latency feels like
0-100ms
Instant
100-300ms
Acceptable
300ms-1s
Perceptible
1s+
Attention wavers
10s+
Abandon
Fundamentals

The React rendering pipeline

Rendering happens in three phases. Understanding them is the prerequisite to optimizing them.

Trigger
state / props / context
Render
interruptible (R18)
Commit
synchronous DOM

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 levels
PriorityTimeoutUse case
ImmediateSyncCritical errors, text input
User blocking250msClicks, interactions
Normal5sNetwork responses
Low10sBackground updates
IdleNeverPrefetching
React 18

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.

Traditional · blockingUI frozen ~500ms
Concurrent · interruptibleResponsive · ~50ms chunks

↑ user can interact between every chunk

Automatic batching
jsx
1// React 18: both updates are batched into a single re-render2setTimeout(() => {3  setCount(c => c + 1);4  setFlag(f => !f);5}, 1000);
Transitions
jsx
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});
Suspense
jsx
1<Suspense fallback={<Spinner />}>2  <UserProfile /> {/* can suspend while loading data */}3</Suspense>
Streaming SSR: server → client
Chunk 1Instant
<html>
<header>
<nav>
Shell
Chunk 2200ms
<Suspense>
<Spinner/>
</Suspense>
Suspense boundary
Chunk 3500ms
<UserData>
John Doe
</UserData>
Data resolved
Common issues

Where React apps slow down

Five patterns account for most real-world React jank. Each has a clear fix, and measurable impact.

01

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.

SolutionReact.memo
jsx
1// Solution: memoize so it skips re-render when props are unchanged2const ExpensiveChild = React.memo(() => {3  // expensive calculations4  return <div>Content</div>;5});
Render time
180ms
5ms
97% faster
Renders / second
15
200+
13× more
CPU usage
85%
12%
73% lower
02

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.

SolutionuseCallback + useMemo
jsx
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}
Child re-renders
every render
on dep change
90-95% fewer
Memory allocations
high
low
60% lower
Interaction latency
120ms
25ms
79% faster
03

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.

Components re-rendering
50+
5-10
80-90% fewer
Update time
300ms
45ms
85% faster
04

Large lists without virtualization

Rendering thousands of DOM nodes when only ~20 are visible. Virtualization renders only what’s on screen.

DOM nodes
10,000
~20
500× fewer
Memory
500MB+
< 50MB
10× lower
Scroll FPS
15-30
60
smooth
05

Expensive computations in render

Filtering and sorting on every render is wasteful for large datasets. useMemo caches the result until inputs change.

SolutionuseMemo
jsx
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}
Processing time
45ms/render
0ms cached
100% on re-render
Typing lag
150-200ms
15-25ms
85-90% faster
CPU usage
70%
15%
79% lower
Techniques

The memoization toolkit

Three tools, each with a clear job, and a clear cost if misused.

useMemo: cache an expensive result
jsx
1const expensiveResult = useMemo(() => {2  return performExpensiveCalculation(data);3}, [data]); // recomputes only when `data` changes
Rule of thumb
Use useMemo for calculations over ~5ms. Avoid it for trivial operations. The memoization overhead isn’t free.
Without useCallback
jsx
1// New function every render → MemoizedChild re-renders every time2function Parent() {3  const handleClick = () => console.log('Button clicked');4  return <MemoizedChild onClick={handleClick} />;5}
With useCallback
jsx
1// Stable reference → MemoizedChild renders once2function Parent() {3  const handleClick = useCallback(() => {4    console.log('Button clicked');5  }, []); // never changes6  return <MemoizedChild onClick={handleClick} />;7}
Reach for it when
  • Passing callbacks to memoized children
  • A value feeds another hook’s dependency array
  • A component is genuinely expensive and the parent re-renders often
Skip it when
  • The component is simple and fast
  • Props change on nearly every render anyway
  • You’re optimizing before measuring
Pitfalls

Seven ways to make it worse

The most common mistakes we see in review, each shown as the problem, then the fix.

Pitfall 1

Premature optimization

Profile first. Memoization adds complexity and overhead, so apply it to measured bottlenecks only.

Problem
jsx
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}
Solution
jsx
1function Simple({ name }) {2  return <div onClick={() => log(name)}>Hello, {name}</div>;3}
Pitfall 2

Mutating props or state

Same reference → React can’t detect the change. Always create new objects/arrays.

Problem
jsx
1const addTodo = (text) => {2  todos.push({ id: Date.now(), text }); // mutates3  setTodos(todos);                      // same ref, no re-render4};
Solution
jsx
1const addTodo = (text) => {2  setTodos([...todos, { id: Date.now(), text }]); // new array3};
Pitfall 3

Incorrect dependency arrays

Missing deps capture stale values and cause subtle, hard-to-reproduce bugs.

Problem
jsx
1const fetchResults = useCallback(() => {2  api.search(query).then(setResults); // uses query…3}, []); // …but it's missing from deps → stale closure
Solution
jsx
1const fetchResults = useCallback(() => {2  api.search(query).then(setResults);3}, [query]); // correct dependency
Pitfall 4

Components defined inside render

A new identity each render unmounts and remounts, destroying state and DOM.

Problem
jsx
1function Parent() {2  // new component identity every render → unmount/remount3  function Child() { return <input />; }4  return <Child />; // loses input state on every Parent render5}
Solution
jsx
1function Child() { return <input />; }2function Parent() {3  return <Child />; // stable identity, keeps state4}
Pitfall 5

Index as key in dynamic lists

Indices misidentify elements on reorder/insert, so state lands on the wrong row.

Problem
jsx
1{todos.map((todo, index) => (2  <TodoItem key={index} todo={todo} /> // breaks on reorder/insert3))}
Solution
jsx
1{todos.map((todo) => (2  <TodoItem key={todo.id} todo={todo} /> // stable identity3))}
Pitfall 6

Not cleaning up effects

Subscriptions and timers pile up, causing memory leaks and eventual crashes.

Problem
jsx
1useEffect(() => {2  const id = setInterval(tick, 1000);3  // no cleanup, interval leaks after unmount4}, []);
Solution
jsx
1useEffect(() => {2  const id = setInterval(tick, 1000);3  return () => clearInterval(id); // cleanup on unmount4}, []);
Pitfall 7

Large context values

Monolithic context re-renders every consumer. Split by concern.

Problem
jsx
1// One monolithic context, any change re-renders every consumer2const value = { user, setUser, theme, setTheme, settings, cart, setCart };
Solution
jsx
1// Split by concern: consumers re-render only on their slice2const UserContext = createContext();3const ThemeContext = createContext();4const CartContext = createContext();
React 18+

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.

jsx
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.

jsx
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}
Code splitting

Ship less JavaScript up front

Loading everything on first paint inflates the bundle and Time to Interactive. lazy() + Suspense load code on demand.

Route-level code splitting
Initial bundle
2.5MB
450KB
82% smaller
Time to Interactive
6.2s
1.8s
71% faster
First-load JS
850KB
180KB
79% lower
Lighthouse
45
92
2× better
routes.tsx
jsx
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>
Conclusion

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.

Key takeaways
  • 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.
All case studies