Skip to content

5. Performance

Performance questions separate people who memorised useMemo from people who have profiled a real app.


1. Virtual DOM

The Virtual DOM is a lightweight JavaScript object tree that mirrors the real DOM.

js
// What JSX becomes — a plain object
{
  type: "div",
  props: { className: "box", children: [...] }
}

How Rendering Works

  1. State changes
  2. React builds a new Virtual DOM tree
  3. React diffs it against the previous tree
  4. React computes the minimum set of real DOM operations
  5. React applies them in one batch (the "commit" phase)

Why This Is Faster

Real DOM operations are expensive — each one can trigger layout recalculation and repaint. Comparing plain JavaScript objects is cheap. React batches many changes into one DOM write.

Interview Point

The Virtual DOM is not inherently faster than hand-written, perfectly optimised DOM code. Its value is that it gives you near-optimal updates from a declarative description, without you tracking what changed. Say this — it is the answer that shows real understanding.


2. Reconciliation and the Diffing Algorithm

Reconciliation is the process of comparing the two trees.

A general tree-diff is O(n³). React gets it to O(n) with two heuristics:

Heuristic 1 — Different element types produce different trees

jsx
// Before
<div><Counter /></div>

// After
<span><Counter /></span>

React does not try to match them. It destroys the entire old subtree — unmounting Counter and losing its state — and builds the new one from scratch.

Heuristic 2 — Keys identify stable children across renders

jsx
{items.map((item) => <Row key={item.id} {...item} />)}

With keys, React moves existing DOM nodes instead of recreating them.

Same Type = Update In Place

jsx
<div className="before" title="x" />
<div className="after"  title="x" />

React keeps the DOM node and updates only className. Then it recurses into the children.

Interview Point

The key prop and the "different type = full rebuild" rule are the two things that make the algorithm linear. Both are worth naming explicitly.


3. Fiber

React Fiber (React 16+) is the rewritten reconciler. Its point is interruptible rendering.

Old (Stack) reconcilerFiber
RenderingSynchronous, recursiveIncremental, unit-by-unit
InterruptibleNoYes
PrioritisationNoneUrgent vs non-urgent updates

Fiber splits work into small units and can pause after each one to let the browser handle a keystroke or a paint. This is what makes useTransition, Suspense and concurrent features possible.


4. When Does a Component Re-render?

Four causes:

  1. Its state changed
  2. Its props changed
  3. Its parent re-rendered (regardless of props)
  4. A context it consumes changed

The One People Miss

Cause 3. A parent re-render re-renders all children by default, even children whose props are identical.

jsx
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <ExpensiveChild />   {/* re-renders on every click */}
    </>
  );
}

Two Fixes

jsx
// Fix 1 — memo
const ExpensiveChild = React.memo(function ExpensiveChild() { /* ... */ });

// Fix 2 — children prop (no memo needed)
function Parent({ children }) {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}   {/* created by the grandparent, so unchanged */}
    </>
  );
}

Fix 2 works because children is an element created outside this component. Its reference doesn't change when Parent re-renders. Interviewers rarely expect this one.


5. React.memo

Skips re-rendering when props are shallow-equal to last time.

jsx
const UserCard = React.memo(function UserCard({ name, age }) {
  return <div>{name} — {age}</div>;
});

Custom Comparison

jsx
const UserCard = React.memo(Component, (prev, next) => {
  return prev.user.id === next.user.id; // true = skip re-render
});

Note the inverted logic: return true when the props are equal (skip), unlike shouldComponentUpdate, which returns true to render.

When memo Does Nothing

jsx
// A new object every render → shallow compare always fails
<UserCard user={{ name: "Dipak" }} />

// A new function every render → same problem
<UserCard onClick={() => save()} />

React.memo only pays off when props are primitives or stable references. Otherwise you added a comparison cost and got zero benefit.

Interview Point

React.memo is shallow. {a: 1} !== {a: 1}. Wrapping every component in memo makes an app slower, not faster.


6. Optimisation Techniques (Full List)

The "name a few ways to optimise a React app" answer:

Rendering

TechniqueWhat it prevents
React.memoRe-render on unchanged props
useMemoRecomputing an expensive value
useCallbackNew function identity breaking memo
Correct key propsUnnecessary unmount/remount
State colocationKeeping state as low in the tree as possible
Split contextsContext consumers re-rendering for unrelated fields

Bundle

TechniqueEffect
React.lazy + SuspenseRoute-level code splitting
Dynamic import()Load heavy libraries on demand
Tree shakingDrop unused exports
Bundle analyserFind the 400 KB date library you forgot about

Data

TechniqueEffect
Debounce / throttle inputsFewer requests and renders
Pagination or infinite scrollSmaller payloads
React Query cachingDeduplicate identical requests
VirtualisationRender 20 rows, not 10,000

Assets

  • Lazy-load images (loading="lazy")
  • Modern formats (WebP / AVIF)
  • Compression (gzip / brotli)
  • CDN for static assets

7. State Colocation

The cheapest optimisation, and the one people forget.

jsx
// Bad — typing in the modal re-renders the whole page
function Page() {
  const [modalInput, setModalInput] = useState("");
  return (
    <>
      <HugeTable />
      <Modal value={modalInput} onChange={setModalInput} />
    </>
  );
}

// Good — state lives where it is used
function Modal() {
  const [input, setInput] = useState("");
}

Move state down, not up, whenever only one subtree needs it. No memo required.


8. Code Splitting

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

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

function Dashboard() {
  return (
    <Suspense fallback={<Skeleton />}>
      <Chart />
    </Suspense>
  );
}

lazy() takes a function returning a dynamic import(). The bundler emits a separate chunk, fetched only when the component first renders.

Suspense

<Suspense> declares a fallback for anything below it that is not ready — a lazy component, or (in React 19) a promise read with use().

jsx
<Suspense fallback={<Spinner />}>
  <Comments commentsPromise={promise} />
</Suspense>

Interview Point

Best places to split: route boundaries, modals, charts, rich-text editors, and anything below the fold. Do not split tiny components — the extra network request costs more than the bytes saved.


9. List Virtualisation

Rendering 10,000 rows creates 10,000 DOM nodes and freezes the browser. Virtualisation renders only what is visible plus a small buffer.

jsx
import { useVirtualizer } from "@tanstack/react-virtual";

function List({ rows }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{
              position: "absolute",
              top: 0,
              transform: `translateY(${row.start}px)`,
              height: row.size,
            }}
          >
            {rows[row.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

Libraries: @tanstack/react-virtual, react-window, react-virtuoso.


10. Debouncing and Throttling

DebounceThrottle
BehaviourWait until activity stopsRun at most once per interval
Use forSearch input, autosaveScroll, resize, mousemove
jsx
function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (debouncedQuery) fetchResults(debouncedQuery);
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

The input stays fully responsive; only the network call is delayed.


11. React Compiler

Stable as v1.0 since October 2025. A build-time compiler that automatically memoises components and values.

jsx
// You write this
function Cart({ items }) {
  const total = items.reduce((s, i) => s + i.price, 0);
  return <Total value={total} />;
}

// The compiler emits the equivalent of useMemo/useCallback/memo

What It Changes

  • Manual useMemo / useCallback / React.memo become largely unnecessary
  • Your code must follow the Rules of React — no mutation during render, no side effects in the render body
  • eslint-plugin-react-compiler tells you where you break those rules

Interview Point

The Compiler does not make memoisation obsolete as a concept — you still need to explain why re-renders happen. It automates the mechanical part. Mentioning it signals you follow current React, which lands well in 2026 interviews.


12. Measuring Before Optimising

React DevTools Profiler

Records a session and shows which components rendered, how long each took, and why.

Profiler Component

jsx
<Profiler
  id="Cart"
  onRender={(id, phase, actualDuration) => {
    console.log(id, phase, actualDuration);
  }}
>
  <Cart />
</Profiler>

Highlight Updates

DevTools → Settings → "Highlight updates when components render". Flashing borders show you exactly what is re-rendering as you click around.

Interview Point

"I would profile first" is the correct opening to any optimisation question. Premature memoisation adds complexity and memory pressure while often making things slower.


13. SSR vs CSR

CSRSSR
HTML from serverEmpty shellFully rendered
First contentful paintSlowFast
Time to interactiveFaster after loadNeeds hydration
SEOWeakerStrong
Server costLowHigher
Navigation after loadInstantInstant (after hydration)

Hydration

The server sends HTML. The client downloads the JavaScript and attaches event listeners to the existing markup — it does not re-create the DOM.

jsx
// Client entry for SSR
import { hydrateRoot } from "react-dom/client";
hydrateRoot(document.getElementById("root"), <App />);

Hydration Mismatch

If the server HTML and the first client render differ, React warns and may discard the server markup. Usual culprits: Date.now(), Math.random(), window, localStorage, and locale-dependent formatting.

jsx
// Fix: render the client-only value after mount
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;

Interview Point

SSR improves perceived performance and SEO. It does not reduce the JavaScript bundle. Reducing the bundle is what React Server Components do — see Next.js.


14. Styling Options

Grouped here because "how do you style a React component" is usually asked alongside performance.

ApproachExampleTrade-off
Inline stylesstyle={{ color: "red" }}No pseudo-classes, no media queries
CSS fileimport "./App.css"Global scope, name collisions
CSS Modulesimport s from "./A.module.css"Scoped, zero runtime, build step
TailwindclassName="flex gap-4"Fast to write, verbose markup
CSS-in-JSstyled-components, EmotionDynamic styling, runtime cost, poor RSC support
Zero-runtime CSS-in-JSvanilla-extract, PandaDynamic API, extracted at build

Interview Point

CSS-in-JS libraries with a runtime do not work in Server Components (they need React context and browser APIs). That is why the ecosystem moved toward Tailwind, CSS Modules and zero-runtime solutions.

© 2025 DDocs · Dipak's Documentation Guide