Rendering Performance

Load performance gets you a fast first paint; rendering performance keeps the app fast while people use it. One mental model: the main thread is the bottleneck, so render less, render rarely, render elsewhere.

1The frame — it's all the main thread

JavaScript, layout, paint, and your event handlers all run on one thread. While that thread is busy, the page can't respond to a tap or render the next frame — that's jank and bad INP (Lesson 03). Any task over 50 ms is a "long task" that delays interaction. [web.dev] To hit 60fps the browser has ~16ms per frame; blow that budget and frames drop.

So every rendering-performance technique is one of three moves on that single thread:

  1. Render less — skip work that doesn't change the output: cut needless re-renders (memo), and don't render rows nobody can see (virtualization).
  2. Render rarely — collapse a storm of events into the work that matters: debounce / throttle inputs, coalesce visual updates to a frame with rAF, and de-prioritize non-urgent renders (transitions).
  3. Render elsewhere — move CPU-heavy work off the main thread entirely: Web Workers / OffscreenCanvas, or yield so the browser can interleave input.
One-liner

“There's one thread doing JS, layout, paint and your click handlers. Rendering perf is three moves on it: render less, render rarely, render elsewhere.”

2Render less — kill needless re-renders

First, know what actually triggers a React re-render. A component re-renders when its state changes, its parent re-renders, or a context it consumes changes. [react.dev] The one people get wrong: props changing isn't an independent trigger — props change because the parent re-rendered, and a parent re-render re-renders all children by default, changed props or not.

A re-render isn't automatically a DOM update — React diffs the result and only commits real changes. But the render itself (running the function, building elements, diffing) still costs CPU, and across a big tree it adds up. Three tools, each for a different cause:

ToolWhat it memoizesUse when
React.memothe component — skips re-render if props are shallow-equala child re-renders with the same props because its parent re-renders often
useMemoa computed value between rendersan expensive calculation, or a reference you pass to a memoized child / dep array
useCallbacka function's identity between rendersyou pass a callback to a memo'd child (else a fresh fn breaks its memo)
// Child re-renders every time Parent does — even if `row` is unchanged.
const Row = React.memo(function Row({ row, onPick }) { /* ... */ });

function Results({ rows }) {
  // stable identity → Row's memo actually holds
  const onPick = useCallback(id => selectHotel(id), []);
  return rows.map(r => <Row key={r.id} row={r} onPick={onPick} />);
}
Trade-off — memoization is not free, and over-using it is a real anti-pattern. Every memo adds a props comparison on each render; every useMemo/useCallback stores a value and its deps in memory and runs a deps check. Sprinkled everywhere it adds CPU, memory, and a lot of noise — and one wrong/missing dep silently ships a stale value. The rule: measure first, memoize the proven hot path. React's own docs say don't optimize pre-emptively. [react.dev] And the React Compiler (stable in React 19) auto-memoizes for you, removing most hand-written memo calls. [react.dev]
One-liner

“A component re-renders on state, parent, or context — not on props by themselves. memo/useMemo/useCallback stop the wasted ones, but they cost too, so I measure first. Increasingly the React Compiler does this automatically.”

Keys — the cheapest correctness-and-perf bug

React uses the key to match elements across renders. A stable, unique key lets it move a row instead of destroying and rebuilding it. Using the array index as a key on a list that reorders/filters is a classic bug: React mismatches items, so DOM state (input focus, checkbox, the wrong row's component state) sticks to the wrong data — and you lose the perf win because it can't reconcile efficiently. [react.dev]

One-liner

“Keys should be stable IDs from the data, never the array index — index keys corrupt component state on a reordering list and kill reconciliation.”

3Render less — virtualize long lists

The biggest single win on a results page. If a search returns 1,000 hotels, the naive approach mounts 1,000 row components and 1,000× their DOM. Virtualization (windowing) renders only the ~visible rows plus a small overscan buffer, and recycles them as you scroll — so the DOM stays tiny no matter how long the list is. [web.dev] Libraries: TanStack Virtual, react-window, react-virtuoso.

// TanStack Virtual: render only what's in view
const v = useVirtualizer({ count: rows.length, getScrollElement: () => ref.current,
                          estimateSize: () => 96, overscan: 8 });
// v.getVirtualItems() → just the handful of rows on screen
Trade-off — virtualization breaks things that assume the whole list exists in the DOM. Native Ctrl-F / find-in-page only sees rendered rows; anchor links and screen readers can miss off-screen content; SEO crawlers won't index it (so don't virtualize SSR content you need indexed); and variable-height rows (a wrapping hotel name, a promo badge) need measurement and can cause scroll jitter. It's the right answer for big, interactive, client-side lists — not a default for every list. A lighter, CSS-only option is content-visibility:auto, which skips rendering off-screen content while keeping it in the DOM. [web.dev]
One-liner

“A 1,000-result list shouldn't be 1,000 DOM nodes — I virtualize so only the visible rows mount. The cost is find-in-page, a11y, and SEO, so I weigh it; for content I need indexed I'd reach for content-visibility instead.”

4Render rarely — debounce, throttle, rAF, transitions

Some events fire in storms — input on every keystroke, scroll/resize/mousemove dozens of times a second. Doing full work on each one floods the main thread. Four tools to do less work for the same events:

Debounce

wait until the storm stops

Run the work only after events go quiet for N ms. Perfect for a search box: fire the API call once the user pauses typing, not on every key.

Throttle

at most once per interval

Run at a steady max rate during a continuous stream. For scroll/resize handlers where you want periodic updates, not a final one.

requestAnimationFrame

coalesce visual updates to a frame

For anything that paints (scroll-linked animation, a sticky header). Batches DOM reads/writes to just before the next paint — the right "throttle" for visual work. [MDN]

useTransition / useDeferredValue

keep input urgent, render lazily

React marks the expensive re-render (the filtered list) as non-urgent, so the keystroke paints immediately and the heavy update can be interrupted. The React-native answer to a laggy filter. [react.dev]

Debounce vs throttle in one line: debounce fires once after activity stops; throttle fires regularly during activity. Search input → debounce. Scroll position readout → throttle (or rAF).

Trap — debounce/throttle hide latency, they don't remove work; and they're orthogonal to rAF and transitions. Too long a debounce makes the UI feel laggy; too short defeats the point. And a common confusion: debounce/throttle limit how often a handler runs; rAF aligns work to the paint cadence; useTransition changes render priority without dropping any events. They solve different problems — for a janky type-to-filter, useDeferredValue keeps every keystroke responsive without dropping inputs the way a debounce does.
One-liner

“Debounce fires once after typing stops; throttle fires at a steady rate during a stream; rAF aligns visual work to the frame. For a laggy React filter I'd reach for useDeferredValue first — it stays responsive without dropping keystrokes.”

“If I use useDeferredValue, do I still need debounce?”

A favourite follow-up — and the answer is they're not substitutes; they fix different layers. useDeferredValue replaces debounce for the render, but not for the network.

useDeferredValuedebounce
Controlsrender priority of data you already havehow often an expensive side effect fires
Reduces work?No — keeps input responsive, discards stale in-progress rendersYes — intentionally drops intermediate values
Drops keystrokes?No (renders every value, just lazily)Yes (that's the point)
Best forclient-side filter/sort of in-memory dataAPI calls / server hits / expensive effects

The trap: useDeferredValue only changes render scheduling — it doesn't suppress effects. Derive a fetch from the deferred value and it still fires for every committed value. So a real type-ahead uses both: debounce the request (don't hammer the server per keystroke) and useDeferredValue to render the results smoothly. [react.dev]

Concretely — a hotel autocomplete with two separate values in flight: the query the user types (must be instant) and the expensive results render:

const [query, setQuery] = useState('');     // ① updates on EVERY keystroke
const [results, setResults] = useState([]);  // ③ server response — can be 500 rows

// ② DEBOUNCE the network — fetch only once typing pauses 300ms
useEffect(() => {
  if (!query) return setResults([]);
  const id = setTimeout(() => fetchHotels(query).then(setResults), 300);
  return () => clearTimeout(id);            // cancel pending fetch on next key
}, [query]);

// ④ DEFER the heavy render — list lags WITHOUT blocking the input
const deferredResults = useDeferredValue(results);
const isStale = results !== deferredResults;  // dim while new render pending

Type rome fast then keep typing: debounce collapses the 4 keystrokes into one fetch at the 300ms pause (and cancellation avoids out-of-order races); useDeferredValue lets the input paint the next keystroke immediately while the 500-row list renders at low priority. Remove each to see why you need both:

Remove…What breaks
the debounce4 keystrokes → 4 requests: server hammered, wasted bandwidth, responses can arrive out of order and overwrite the right answer.
useDeferredValuerendering the 500-row list blocks every new keystroke → laggy input, bad INP.

So debounce protects the server (throttles work going out, dropping intermediate queries); useDeferredValue protects the input (re-prioritizes work coming back, dropping no keystrokes). If the data is already in memory (no fetch), drop the debounce and use useDeferredValue alone — the composition only appears when a network call and an expensive render are both in play.

One-liner

“They compose, they don't replace each other. useDeferredValue for an expensive render over data I already have; debounce for the network call. An autocomplete that hits the server needs both — and if even the interruptible render pegs the CPU, I debounce the work or move it off-thread.”

5Render elsewhere — get heavy work off the main thread

Sometimes the work is genuinely heavy — parsing a big JSON payload, sorting/filtering 50k records client-side, image processing, on-device search. No amount of memoizing makes a 200ms computation cheap; it just blocks. The move is to take it off the main thread:

Trade-off — a worker isn't free either. There's a startup cost, and crossing the thread boundary serializes data (structured clone) — so passing huge objects back and forth can cost more than the compute you saved. Workers shine for CPU-bound work on data that doesn't ping-pong constantly (use transferable objects / SharedArrayBuffer for big buffers). They don't help with DOM work — that has to be on the main thread, which is exactly why §2–§4 matter.
One-liner

“If it's genuinely heavy CPU work, no memo saves you — move it to a Web Worker so the main thread stays free for input and paint. If I can't move it, I chunk it and yield so I'm not holding a long task.”

6How to diagnose & the Lead framing

Don't guess — measure, then pick the move. The workflow (Lesson 04):

Full loop

Concept: rendering perf = keep the main thread free → render less, rarely, elsewhere. Trade-off: memoization and virtualization both add cost/complexity, so I profile first and memoize the proven hot path, not pre-emptively. Anchor: “Our filter re-rendered a 1,000-row results list on every keystroke and INP was awful; we virtualized the list, wrapped the filtered render in useDeferredValue, and moved the client-side sort into a Web Worker — typing went instant.” Impact: INP is a Core Web Vital and a janky search is the moment a traveler abandons a booking, so this is conversion, not polish. Invite: “If the list were small and server-filtered I'd skip all of it — virtualization and workers earn their complexity only past a real threshold.”

7Check yourself — scenario quiz

Pick an answer; instant feedback. Push-back style, like the round.

1. What is the single underlying reason all these techniques matter?

2. Which of these does not by itself trigger a React re-render of a component?

3. A teammate wraps every component in React.memo and every value in useMemo "to be safe." Your Lead take?

scn: they expect a blanket speedup.

4. A list of hotels can be reordered and filtered. A dev uses the array index as the key. What goes wrong?

5. A search returns 1,000 hotels and scrolling is janky. Most effective fix?

6. When is virtualization the wrong call?

7. Debounce vs throttle — fastest correct mental model?

8. Typing in a filter over a big list lags, but you don't want to drop or delay keystrokes the way a debounce would. React-native fix?

9. A purely client-side sort of 50k records freezes the UI for ~200ms each time. Best approach?

10. What's the catch with offloading work to a Web Worker?

scn: a teammate wants to push all logic into a worker.

11. Your autocomplete uses useDeferredValue to keep typing smooth — so a teammate says “we can drop the debounce on the API call now.” Right?

scn: each keystroke derives a server fetch.

0 / 11 answered

Try this aloud before next session: “Our hotel results page re-renders the whole 1,000-row list on every keystroke and INP is poor on mobile. Walk me through how you'd diagnose it and the fixes you'd reach for — and where you'd stop because the complexity isn't worth it.” Time to 90 seconds.
Good follow-up topics:
“Quiz me out loud, harder” “What does the React Compiler actually do?” “useMemo vs useCallback — concretely?” “Layout thrashing / forced reflow?” “scheduler.yield vs isInputPending?” “Virtualize a variable-height list?”