Skip to the solution
MediumFrontend6 min read

How do you optimize performance in a React application?

228 views

The problem

Question presented to candidate: "An app feels slow. How do you approach making it faster?"

What a strong answer should cover:

  • Measure before changing anything, and measure the right thing: Lighthouse and Core Web Vitals for load, the DevTools Profiler for interaction, the Network panel for data.
  • The categories are distinct and easily confused: load performance, network, render performance, and memory.
  • Load: code splitting with React.lazy at route boundaries, tree shaking, checking the bundle for accidentally-included heavy dependencies, and server rendering for first paint.
  • Network: eliminating waterfalls with Promise.all and loaders, caching with a query library, and preloading.
  • Render: structural fixes first (move state down, lift content up), then memoisation, then context splitting.
  • Lists: virtualisation, which is a different order of magnitude from memoisation.
  • Concurrent features: useTransition and useDeferredValue to keep input responsive during expensive updates β€” perceived performance rather than less work.
  • Images and fonts are frequently the real problem and have nothing to do with React.
  • Know which metric you are moving: LCP, INP, and CLS measure different things.

Clarifying questions expected:

  • "Slow to load, or slow to interact? Those are completely different problems."
  • "Do we have real user metrics, or is this anecdotal?"
  • "Which device and network are we targeting?"

Code / implementation expected: Optional β€” route-level code splitting and a transition are the two most demonstrable.

  • performance
  • optimization
  • memoization
  • rendering

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 10 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactCode splitting, virtualisation, and a transition keeping input responsive
import { useState, useMemo, useDeferredValue, useTransition, lazy, Suspense } from "react";

// ── LOAD: a route-level split point. The bundler emits a separate chunk that
//    is only fetched when this actually renders. ─────────────────────────────
const HeavyPanel = lazy(
  () => new Promise((resolve) =>
    setTimeout(() => resolve({
      default: () => (
        <div style={{ background: "#eef", padding: 10, borderRadius: 6 }}>
          Heavy panel β€” arrived as its own chunk, not in the initial bundle.
        </div>
      ),
    }), 700)),
);

const ROWS = Array.from({ length: 5000 }, (_, i) => "Row " + i + " β€” item name here");

// ── RENDER: a hand-rolled window. Only the visible slice is rendered, which is
//    a different order of magnitude from memoising 5000 rows. ───────────────
const ROW_H = 24;
const WINDOW = 300;

function VirtualList({ rows }) {
  const [scrollTop, setScrollTop] = useState(0);
  const start = Math.max(0, Math.floor(scrollTop / ROW_H) - 3);
  const visible = rows.slice(start, start + Math.ceil(WINDOW / ROW_H) + 6);

  return (
    <div
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height: WINDOW, overflow: "auto", border: "1px solid #ddd", borderRadius: 6 }}
    >
      {/* A spacer gives the scrollbar the full height */}
      <div style={{ height: rows.length * ROW_H, position: "relative" }}>
        {visible.map((r, i) => (
          <div key={start + i} style={{
            position: "absolute", top: (start + i) * ROW_H, height: ROW_H,
            fontSize: 13, lineHeight: ROW_H + "px", paddingLeft: 8,
          }}>
            {r}
          </div>
        ))}
      </div>
      <p style={{ position: "sticky", bottom: 0, background: "#fff", margin: 0, fontSize: 12, padding: 4 }}>
        rendering <strong>{visible.length}</strong> of {rows.length} rows
      </p>
    </div>
  );
}

export default function App() {
  const [showHeavy, setShowHeavy] = useState(false);
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  // PERCEIVED: the deferred value lags behind, so typing never waits for the
  // expensive filter. The same work happens β€” just at a lower priority.
  const deferred = useDeferredValue(query);
  const filtered = useMemo(
    () => ROWS.filter((r) => r.toLowerCase().includes(deferred.toLowerCase())),
    [deferred],
  );

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 540 }}>
      <h4 style={{ marginTop: 0 }}>Code splitting</h4>
      <button onClick={() => setShowHeavy((s) => !s)}>
        {showHeavy ? "hide" : "load"} the heavy panel
      </button>
      {showHeavy && (
        <Suspense fallback={<p style={{ color: "#888" }}>fetching chunk…</p>}>
          <HeavyPanel />
        </Suspense>
      )}

      <h4>Virtualised list of {ROWS.length} rows</h4>
      <input
        value={query}
        onChange={(e) => startTransition(() => setQuery(e.target.value))}
        placeholder="filter β€” typing stays responsive"
        style={{ width: "100%", marginBottom: 6 }}
      />
      <div style={{ opacity: isPending || query !== deferred ? 0.6 : 1, transition: "opacity .15s" }}>
        <VirtualList rows={filtered} />
      </div>

      <p style={{ color: "#666", fontSize: 13 }}>
        Scroll the list: only about a dozen rows exist in the DOM at any moment.
        Type quickly in the filter: the input never stutters, because the
        expensive filter runs at a lower priority and the list dims while it
        catches up.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 65 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextMedium

Server Actions β€” how do mutations work without API routes in Next.js 15?

Continue