Engineering Notes
Home/Frontend/React · Advanced/Performance Optimization
Frontend · React · Advanced

Performance Optimization

Measure first, then cut unnecessary work. See memo skip a 500-row list when the parent re-renders, then calculate how list virtualization shrinks the DOM, alongside code splitting and transitions.

🎨 Frontend● Advanced⏱ 14 min read▶ 2 interactive🛠️ 4 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Profiling before optimizing; why components re-render
  • React.memo, useCallback/useMemo pairing and custom comparators
  • Code splitting with lazy + Suspense
  • List virtualization and deprioritizing updates

📚 Before you start

Part 1Measuring and `memo`

Prerequisite: frontend/react/hooks/06-usememo, 07-usecallback (the mechanics of each hook) and frontend/react/core/04-rendering_and_reconciliation (render vs commit, fiber, lanes). This part is about the applied discipline of when to reach for those tools, plus React.memo, which those two prerequisites don't cover.

1Profile Before You Optimize

Every optimization in this topic has a real cost — extra memory for cached values, extra comparison work per render, more code to maintain. Applying any of them without evidence of an actual problem usually makes things slower, not faster: memo still has to run a comparison on every parent render, and useMemo/useCallback still allocate a dependency array to check every render.

The React DevTools Profiler tab is the source of truth: record an interaction, and it shows which components rendered, how long each took, and — critically — why each one rendered (a badge naming the changed prop, state, context, or "parent re-rendered"). Optimize the components the profiler flags as both slow and frequently re-rendering; leave everything else alone.

2Why a Component Re-renders at All

Three, and only three, root triggers cause a component to render:

  1. Its own state changed (useState/useReducer).
  2. Its parent re-rendered (and the component isn't bailed out — see Section 3).
  3. A context it subscribes to changed identity (frontend/react/advanced/01-context_api_in_depth).

Everything else — a prop changing, a key changing — is a consequence of one of these three, propagated down through the tree.

3React.memo: Skipping Re-render on Unchanged Props

memo wraps a component so that, on a parent re-render, React first shallow-compares the new props object to the old one; if every prop is Object.is-equal, React skips calling the component function and reuses last render's output.

tsx
const ExpensiveRow = memo(function ExpensiveRow({ label, value }: { label: string; value: number }) {
  console.log("rendering", label);
  return <tr><td>{label}</td><td>{value}</td></tr>;
});

function Table({ rows, unrelatedCounter }: { rows: Row[]; unrelatedCounter: number }) {
  // unrelatedCounter changing re-renders Table, but each ExpensiveRow
  // only re-renders if ITS OWN row object changed reference
  return <table>{rows.map((r) => <ExpensiveRow key={r.id} label={r.label} value={r.value} />)}</table>;
}

Without memo, every Table re-render would re-render every ExpensiveRow, regardless of whether that row's data changed.

4The memo + useCallback/useMemo Pairing

memo's shallow comparison is only useful if the props it's comparing are actually stable across renders where nothing relevant changed. A function or object prop built inline defeats it immediately:

tsx
function Table({ rows }: { rows: Row[] }) {
  // BAD: a new function every render — ExpensiveRow's memo never bails out
  return <>{rows.map((r) => <ExpensiveRow key={r.id} row={r} onSelect={() => select(r.id)} />)}</>;
}

function Table({ rows }: { rows: Row[] }) {
  // GOOD: onSelect is stable across renders where `select` didn't change
  const onSelect = useCallback((id: string) => select(id), [select]);
  return <>{rows.map((r) => <ExpensiveRow key={r.id} row={r} onSelect={onSelect} />)}</>;
}

This is the applied version of the identity rule from useMemo/useCallback's own theory: those hooks exist largely to make memo work. Using one without the other is a common half-fix — wrapping a child in memo while the parent still hands it a fresh object every render accomplishes nothing.

5memo with a Custom Comparator

memo's second argument overrides the default shallow comparison, useful when a prop is an object whose identity changes but whose relevant fields don't:

tsx
const UserCard = memo(
  function UserCard({ user }: { user: { id: string; name: string; lastSeen: number } }) {
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => prevProps.user.id === nextProps.user.id && prevProps.user.name === nextProps.user.name,
  // deliberately ignoring lastSeen — this card doesn't render it
);

Return true from the comparator to skip the re-render (props treated as equal) — this is the inverse convention from shouldComponentUpdate, and a common source of bugs when developers get the boolean backwards.

Under the HoodWhat the Shallow Comparison Actually Does▾
memo's default comparator loops keys of both prop objects with Object.is; a custom comparator replaces that loop entirely
memo's default comparator loops keys of both prop objects with Object.is; a custom comparator replaces that loop entirely

memo's default comparator is not a deep equal — it's shallowEqual, which does exactly this:

text
function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) return true;
  if (typeof objA !== "object" || objA === null) return false;
  if (typeof objB !== "object" || objB === null) return false;
  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);
  if (keysA.length !== keysB.length) return false;
  for (const key of keysA) {
    if (!Object.is(objA[key], objB[key])) return false;
  }
  return true;
}

This runs on the fiber during beginWork for a memo-type fiber: React reads oldProps from the current (already-committed) fiber and newProps from the pending work, runs the comparator, and if it returns true, React bails out — it clones the child fiber subtree from the previous commit instead of calling the function again. Two things this makes precise:

  • One level deep only. A user object with an unchanged id but a mutated preferences object nested inside it passes the default shallow comparison because Object.is(oldUser, newUser) already short-circuits true when the reference itself didn't change — but if a new user object is passed with only preferences different, shallowEqual correctly returns false because user itself is a top-level key. The pitfall is the other direction: a prop that is itself a nested object whose leaf value changed while a wrapper stayed structurally similar can still slip through a custom comparator that doesn't check deep enough.
  • memo cannot stop context propagation. As covered in topic 01 Section 3, propagateContextChange sets childLanes on every ancestor of a matching consumer, and a memo fiber with non-zero childLanes is not allowed to bail out even if its shallow prop comparison passes — React must still descend through it. memo only protects against parent re-renders via props; it does nothing against context or the component's own state.

⚠️ Common Pitfalls

  1. Wrapping every component in memo "for safety." The comparison itself costs a pass over every prop, every render, whether or not it ever helps — blanket memo usage without profiling evidence commonly makes an app slower.
  2. memo around a component whose parent always passes new object/function props. The comparison always fails, so memo adds a wasted comparison on top of the render it couldn't prevent — pair it with useMemo/useCallback at the prop's source, per Section 4.
  3. Forgetting memo does nothing against context. A memo-wrapped consumer still re-renders on every provider value change (Section 6) — the fix is splitting contexts or a selector-based store, not more memo.
  4. Returning the comparator's boolean backwards. memo's custom comparator returns true to SKIP a render (props "equal") — the opposite of a typical equals(a, b) utility's intuitive "true means different" framing bugs people expect.
  5. Optimizing components the Profiler didn't flag. Time spent memoizing a component that renders in under a millisecond and rarely re-renders is time not spent on the one that actually causes jank.
  6. Passing children as JSX and expecting memo to catch it. <Wrapper><Child /></Wrapper> creates a new children element (and thus a new prop value) on every parent render unless the JSX itself is hoisted or the element reference is otherwise stabilized — this is a common "why doesn't memo work here" report.

📋 Quick Reference

ToolProtects againstDoes NOT protect against
React.memoRe-render caused by unchanged props on parent re-renderOwn state change, context change, non-memoized prop sources
useCallbackNew function identity breaking memo/useMemo/useEffect depsAnything if the memoized child isn't itself wrapped in memo
useMemoRecomputing an expensive value on every renderAnything if nothing downstream depends on referential stability
Custom memo comparatorFalse negatives from objects with irrelevant changing fieldsBugs from the inverted true-means-skip boolean convention
StepTool
Find what's actually slowReact DevTools Profiler, record + inspect "why did this render"
Stop a child re-rendering on unrelated parent rendersmemo
Make a function/object prop worth comparinguseCallback / useMemo at the source
Stop re-renders driven by context, not propsSplit contexts or selector-based store (topic 01, Section 7)
Part 2Splitting, Virtualizing, and Deprioritizing

Continuation of Part 1. This part covers reducing how much work ships to the browser (code splitting) and how much DOM exists at once (virtualization), then the concurrent APIs that let React deprioritize work instead of doing less of it.

1Code Splitting with lazy + Suspense

Every component imported at the top of a bundle ships in the initial JavaScript payload, whether or not it's ever rendered on first paint. React.lazy defers a component's module (and everything it imports) into a separate chunk, fetched only when first rendered:

tsx
import { lazy, Suspense } from "react";

const SettingsPanel = lazy(() => import("./SettingsPanel"));

function App() {
  const [showSettings, setShowSettings] = useState(false);
  return (
    <>
      <button onClick={() => setShowSettings(true)}>Settings</button>
      {showSettings && (
        <Suspense fallback={<Spinner />}>
          <SettingsPanel />
        </Suspense>
      )}
    </>
  );
}

import("./SettingsPanel") is a dynamic import — bundlers (Webpack, Vite, Turbopack) treat it as a split-point boundary and emit a separate chunk file. lazy wraps the promise it returns so that rendering <SettingsPanel /> before the chunk loads throws the promise, which the nearest Suspense boundary catches to show fallback until it resolves. This is the same throw-a-promise mechanism Server Components' Suspense integration uses (frontend/ecosystem/03-nextjs_server_components), just triggered by a network fetch for JS instead of data.

2Route-Level and Component-Level Splitting

The highest-value split points are ones a user doesn't visit every session — a settings modal, an admin-only route, a rich text editor. Splitting a component that renders unconditionally near the top of the tree provides no benefit; it downloads immediately anyway, just as a separate network request instead of inline in the main bundle (and pays extra round-trip latency for nothing).

3List Virtualization

Rendering 10,000 DOM nodes for a 10,000-row list is slow regardless of how well-memoized each row is — the browser still has to lay out and paint every one. Virtualization renders only the rows currently visible in the scroll viewport (plus a small overscan buffer), keeping the DOM node count constant regardless of list length:

tsx
function VirtualList({ items, rowHeight, viewportHeight }: { items: Item[]; rowHeight: number; viewportHeight: number }) {
  const [scrollTop, setScrollTop] = useState(0);
  const startIndex = Math.floor(scrollTop / rowHeight);
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + 1; // +1 overscan row
  const endIndex = Math.min(items.length, startIndex + visibleCount);

  return (
    <div style={{ height: viewportHeight, overflow: "auto" }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
      <div style={{ height: items.length * rowHeight, position: "relative" }}>
        {items.slice(startIndex, endIndex).map((item, i) => (
          <div key={item.id} style={{ position: "absolute", top: (startIndex + i) * rowHeight, height: rowHeight }}>
            {item.label}
          </div>
        ))}
      </div>
    </div>
  );
}

The outer scrollable div reports its real height (items.length * rowHeight) so the scrollbar behaves correctly, while only the rows within the viewport are ever mounted — an onScroll handler recomputes which slice is visible and React reconciles just that slice. Libraries like @tanstack/react-virtual and react-window implement this same core idea with variable-height support, horizontal lists, and better scroll performance than a naive onScroll handler.

4useTransition: Marking Updates as Low Priority

startTransition tells React that a state update is not urgent — it can be interrupted by, and should yield to, more urgent updates like the keystroke that triggered it:

tsx
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value); // urgent — the input must feel instant
    startTransition(() => {
      setResults(filterHugeList(e.target.value)); // expensive — can wait
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Updating…</span>}
      <ResultsList results={results} />
    </>
  );
}

Without startTransition, a single keystroke would synchronously run filterHugeList before the browser can paint the updated input value, making typing feel laggy on a large dataset. With it, the input update commits immediately in its own (default-priority) render pass, while the results update is processed in a low-priority lane that React works on in the background and can abandon mid-render if a newer keystroke arrives.

5useDeferredValue: Deferring a Value Instead of a Setter

useDeferredValue achieves a similar effect without wrapping the state setter itself — useful when you don't own the code that calls setState (e.g., a value from a parent or a form library):

tsx
function SearchPage({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const results = useMemo(() => filterHugeList(deferredQuery), [deferredQuery]);
  const isStale = query !== deferredQuery;

  return (
    <div style={{ opacity: isStale ? 0.6 : 1 }}>
      <ResultsList results={results} />
    </div>
  );
}

deferredQuery initially equals query, but when query changes rapidly, React keeps rendering deferredQuery at its previous value in a low-priority background render, only updating it once things settle — comparing query !== deferredQuery is the standard way to show a stale-content indicator during that window.

Under the HoodHow a Transition Actually Yields▾
A default-priority keystroke render interrupts an in-progress transition render; the transition resumes from scratch in a background lane after the urgent one commits
A default-priority keystroke render interrupts an in-progress transition render; the transition resumes from scratch in a background lane after the urgent one commits

frontend/react/core/04-rendering_and_reconciliation (Part 2) established that React's work loop checks shouldYieldToBrowser() after every fiber and that lanes is the priority bitmask driving what gets worked on next. startTransition and useDeferredValue are built directly on that mechanism:

  • Code inside startTransition's callback has its resulting updates tagged with the Transition lane instead of the default Sync/InputContinuous lane a plain setState gets.
  • When React's scheduler has both a Transition-lane render in progress and a new Sync-lane update arrives (the next keystroke), it does not finish the low-priority work first — it abandons the in-progress work-in-progress tree for the transition, processes the urgent update to completion and commits it, and then restarts the transition's render from scratch, now starting from the newer committed state.
  • This is why transition work must be a pure function of state, with no side effects that assume "runs to completion" — a partially-rendered, then-discarded work-in-progress tree is a normal, expected occurrence for transition-lane work, not a bug.
  • isPending (from useTransition) and the query !== deferredQuery staleness check (from useDeferredValue) both work the same way under the hood: they read whether a lower-priority render tagged against that state is still outstanding.

None of this changes single-threaded JavaScript into multi-threaded work — it is cooperative scheduling within one thread, using the same time-slicing (shouldYieldToBrowser, roughly a 5ms budget) already described in the reconciliation topic. The gain is entirely about ordering: urgent work is allowed to cut in line ahead of expensive work that was already in flight.

⚠️ Common Pitfalls

  1. Wrapping the urgent update itself in startTransition. startTransition(() => setQuery(value)) makes the input's own display lag, defeating the entire point — only the expensive derived update belongs inside the transition; the input's own state stays a normal setState.
  2. Virtualizing a short list. Virtualization's bookkeeping (tracking scrollTop, computing visible ranges, absolute positioning) costs more than it saves below roughly a few hundred rows — profile first, same as memo.
  3. Splitting a component that always renders on first paint. The network round-trip for that chunk delays the very content the split was supposed to speed up; only split what's conditional or below the fold.
  4. Expecting useDeferredValue to debounce. It has no timer — it's tied to React's scheduling, not to elapsed time, so it does not skip intermediate values the way a setTimeout-based debounce does; it can still re-render on every keystroke if the machine is fast enough to keep up.
  5. Assuming startTransition makes the wrapped work faster. It doesn't reduce the actual computation — filterHugeList still takes exactly as long; the benefit is entirely about not blocking urgent updates while it runs.
  6. Forgetting virtualized rows need a stable, item-identity key. Reusing array index as key here interacts badly with absolute positioning during fast scrolling — reuse the guidance from frontend/react/core/04-rendering_and_reconciliation on key choice.

📋 Quick Reference

ProblemTool
Large JS bundle, most of it unused on first paintReact.lazy + Suspense, split at route/rarely-used-component boundaries
Thousands of DOM nodes for a long listVirtualization (render only the visible slice)
Typing feels laggy because of an expensive derived updateuseTransition around the expensive setState
Expensive re-render driven by a prop/value you don't control the setter foruseDeferredValue
Need to know if a low-priority update is still pendingisPending (from useTransition) or value !== deferredValue
MechanismFact
lazyDynamic import() becomes a bundler split point; throws a promise Suspense catches
VirtualizationDOM node count stays O(viewport), not O(list length)
Transition laneA separate priority bitmask from default setState's lane
InterruptionAn urgent Sync-lane update discards and later restarts an in-flight Transition-lane render
Underlying primitiveSame cooperative time-slicing (shouldYieldToBrowser) as the base work loop

This closes out frontend/react/advanced. frontend/ecosystem builds the surrounding toolchain — routing, styling, and state libraries — on top of these primitives.

🧠 Key takeaways

  • Profile first (React DevTools Profiler, Performance panel); re-renders aren't bad, slow ones are.
  • memo skips re-rendering when props are shallow-equal; pair with useMemo/useCallback for stable props.
  • Code-split routes and heavy components with lazy + Suspense.
  • Virtualize long lists; use useTransition/useDeferredValue to keep input responsive.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How do you approach React performance problems?core▾

Measure with React DevTools Profiler and browser performance tools, identify slow renders or expensive commits, then fix: colocate state, split components, memoize, virtualize long lists, lazy-load code, and defer non-urgent updates.

Follow-ups interviewers ask
  • Which Web Vitals matter? LCP, INP (responsiveness), CLS.
Q2How do you render very long lists efficiently?core▾

Virtualization (react-window, TanStack Virtual) renders only visible rows; also paginate or use infinite loading, with stable keys.

Follow-ups interviewers ask
  • Why does it help? DOM size and render work stay constant.
Q3How do you reduce bundle size?core▾

Code splitting with React.lazy/dynamic imports per route and heavy components, tree-shaking, avoiding large dependencies, analyzing bundles, and server components that ship no JS.

Follow-ups interviewers ask
  • Tool? Bundle analyzers (webpack-bundle-analyzer, source-map-explorer).
Q4What are transitions and useDeferredValue for?senior▾

Marking updates as non-urgent so React keeps urgent interactions (typing) responsive while rendering expensive updates (filtering a big list) in the background, interruptible.

Follow-ups interviewers ask
  • Difference? startTransition wraps the update; useDeferredValue defers a value you receive.
Q5What causes layout shift and how do you prevent it?core▾

Images/ads/fonts loading without reserved space, or content inserted above existing content. Set width/height or aspect-ratio, reserve space for dynamic content, and use font-display strategies.

Follow-ups interviewers ask
  • Metric? CLS (Cumulative Layout Shift).

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. memo(Child) still re-renders on every parent render. Likely cause?
Unstable props.
2. 10,000 rows at 40 px with an 800 px viewport: roughly how many DOM rows virtualized?
Viewport + overscan.
3. What should you do before optimizing?
Find the real cost.
4. Keeping typing responsive while filtering a huge list:
Deprioritize the slow update.