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.
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:
memo), and don't render rows nobody can see (virtualization).rAF, and de-prioritize non-urgent renders (transitions).“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.”
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:
| Tool | What it memoizes | Use when |
|---|---|---|
React.memo | the component — skips re-render if props are shallow-equal | a child re-renders with the same props because its parent re-renders often |
useMemo | a computed value between renders | an expensive calculation, or a reference you pass to a memoized child / dep array |
useCallback | a function's identity between renders | you 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} />);
}
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]
“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.”
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]
“Keys should be stable IDs from the data, never the array index — index keys corrupt component state on a reordering list and kill reconciliation.”
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
content-visibility:auto, which skips rendering off-screen content while keeping it in the DOM. [web.dev]
“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.”
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:
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.
Run at a steady max rate during a continuous stream. For scroll/resize handlers where you want periodic updates, not a final one.
requestAnimationFrameFor 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 / useDeferredValueReact 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).
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.
“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.”
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.
useDeferredValue | debounce | |
|---|---|---|
| Controls | render priority of data you already have | how often an expensive side effect fires |
| Reduces work? | No — keeps input responsive, discards stale in-progress renders | Yes — intentionally drops intermediate values |
| Drops keystrokes? | No (renders every value, just lazily) | Yes (that's the point) |
| Best for | client-side filter/sort of in-memory data | API 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 debounce | 4 keystrokes → 4 requests: server hammered, wasted bandwidth, responses can arrive out of order and overwrite the right answer. |
useDeferredValue | rendering 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.
“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.”
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:
postMessage and it posts results back. Great for heavy compute that would otherwise freeze the UI. [MDN] (Comlink makes the messaging feel like a normal function call.)<canvas> (a map, a chart) from a worker, so animation/drawing never competes with the UI thread. [MDN]await scheduler.yield() (or scheduler.postTask) is the modern way; setTimeout(0) the old fallback. This is the key INP fix for long tasks. [web.dev]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.
“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.”
Don't guess — measure, then pick the move. The workflow (Lesson 04):
memo.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.”
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