{}The Interview
Handbook

Tracks / React

Frontend forensics

seniorSignal round 6 questions · 15 min read performancerenderinghydrationdebugging

Questions in this set 6
  1. 01Typing one character in a search box re-renders 340 components. The profiler blames the context provider. Fix it.
  2. 02The site works locally, and 5% of production users get hydration errors. Where do you start?
  3. 03Lighthouse gives you 96. Real-user LCP is 4.8 seconds. Explain the gap.
  4. 04Users report the app gets slower the longer the tab is open. Find the leak.
  5. 05Two engineers disagree: one wants useEffect for data fetching, the other wants React Query. Adjudicate.
  6. 06Your bundle is 1.8 MB gzipped and the team wants to switch to a lighter framework. What do you do?

Frontend interviews above mid-level stop asking what useMemo does and start asking why the profiler looks like this. The recurring skill is the same one backend engineers need: read the evidence, form a hypothesis the evidence could falsify, and know which fix addresses the cause rather than the symptom.

01

Typing one character in a search box re-renders 340 components. The profiler blames the context provider. Fix it.

Start by explaining why memo failed, because that is the actual test. React.memo compares props. A context update does not travel through props: any component calling useContext subscribes directly and re-renders when the provider's value identity changes, regardless of memoisation anywhere above it. Memoising the table is irrelevant if the table — or anything inside it — reads that context.

The fixes, in the order I would apply them:

  1. Stop creating a new value object every render. value={{ user, theme, filters, setFilters }} inline changes identity on every provider render even when nothing changed. useMemo on the value is table stakes, not the fix.
  2. Split the context by change frequency. This is the real fix. filters changes on every keystroke; user and theme change approximately never. One context per concern means a keystroke wakes only the components that read filters.
  3. Split state from dispatch. setFilters never changes identity, so components that only write can read a separate dispatch context and never re-render at all. This single change typically removes half the renders in a form-heavy app.
  4. Move the state down. If only the search box and the table need filters, it does not belong at app level. Colocation is the cheapest performance fix in React and the one people reach for last.
  5. Decouple typing from filtering. Keep the input's value in local state so it stays instant, and drive the expensive work from useDeferredValue — so the table re-renders a few times a second rather than per keystroke.
  6. If shared state genuinely must be global and fine-grained, use a store with selectors (Zustand, Redux Toolkit, Jotai). Context has no selector mechanism by design; steps 2-4 are you working around that.
jsx
const FiltersValue = createContext(null);
const FiltersDispatch = createContext(null);          // stable identity forever

function SearchBox() {
  const dispatch = useContext(FiltersDispatch);       // never re-renders on value change
  const [text, setText] = useState("");               // local: typing is always instant
  const deferred = useDeferredValue(text);
  useEffect(() => { dispatch({ type: "query", value: deferred }); }, [deferred, dispatch]);
  return <input value={text} onChange={e => setText(e.target.value)} />;
}
02

The site works locally, and 5% of production users get hydration errors. Where do you start?

Hydration assumes the client's first render produces exactly the markup the server produced. "5% of users" means something varies per user; "only in production" usually means the two environments differ in ways your laptop hides.

Candidates, ordered by how often they are the answer:

  • Locale and timezone. toLocaleDateString() or Intl.NumberFormat without an explicit locale, or a date rendered in server time then re-rendered in the user's. Users outside your timezone mismatch; you never do.
  • Anything read from the browser during render: localStorage (a returning user has a saved theme, a new user does not — hence a fraction of users), matchMedia, client-read cookies, window.innerWidth driving a responsive branch.
  • Randomness or time: Math.random() for an id, Date.now(), relative timestamps that tick over between server render and hydration.
  • Invalid HTML nesting. <div> inside <p>, <p> inside <p>. The browser silently repairs the DOM while parsing, so the tree React finds is not the tree the server sent. Invisible in the source, obvious once you know to look.
  • Browser extensions injecting nodes — genuinely responsible for a slice of production-only warnings, and something you mitigate rather than fix.
  • A/B tests or feature flags evaluating differently on each side, which by construction affects a percentage of users.
  • Stale CDN HTML meeting a new JS bundle after a deploy: users on a cached page get old markup hydrated by new code.

The method: find the mismatch, do not guess. React 18+ logs the diverging content; capture it from real sessions with your error reporter, then segment affected users by locale, timezone, new-vs-returning and release version. That correlation usually names the cause in one pass.

03

Lighthouse gives you 96. Real-user LCP is 4.8 seconds. Explain the gap.

Lighthouse is a lab measurement: one run, one device profile, one network profile, usually a warm cache, from a machine near your CDN, with no consent banner, no logged-in state and no extensions. Field data is the 75th percentile of real users — mid-range Android phones, congested mobile networks, far from your edge, cold cache, ad blocker installed.

Specific causes of a gap this size, all invisible to a default Lighthouse run:

  • Device distribution. The median real Android device is considerably slower than people assume, and JavaScript execution is where that difference bites hardest.
  • Cold cache. Your local reload and the default lab run both benefit from a warm cache; first-time visitors download everything.
  • Logged-in and personalised pages cannot be statically served, so real TTFB is far worse than the anonymous landing page that got tested.
  • Consent banners and third-party tags that only exist in production: a tag manager pulling six more scripts, a chat widget, an anti-flicker snippet that literally hides the page until an A/B script loads.
  • Geography. Your edge may be excellent in your region and mediocre where a third of your users live.
  • A different LCP element per user. A personalised hero, a late banner, or a cookie-driven layout can make a slower element the LCP for real traffic.

The fix is procedural first: stop optimising against the lab number. Instrument real users with the web-vitals library, report LCP with element attribution, and segment by device class, country, connection and page type. Then fix the worst real segment.

04

Users report the app gets slower the longer the tab is open. Find the leak.

The method matters more than the list:

  1. Reproduce with a repeated action. Navigate A → B ten times. A healthy app returns to roughly the same heap size after each cycle.
  2. Heap snapshots. One before, ten cycles, force GC, one after, then Comparison view sorted by delta. Look for a class whose instance count grows by exactly the number of cycles — that correlation identifies the culprit.
  3. Find "Detached" nodes. Detached HTMLDivElement means DOM removed from the document but still referenced by JS. Expand the retainer chain; that path names the bug.
  4. The Performance monitor (live heap, DOM node count, listener count) confirms a leak exists in thirty seconds, before you invest in snapshots. A listener count that only rises is conclusive.

The usual causes — note that every one is "cleanup not done":

  • addEventListener on window/document with no removal on unmount.
  • setInterval never cleared, keeping its closure and captured state alive.
  • A WebSocket or EventSource per mount, never closed, so an hour of navigation leaves fifty open sockets buffering data.
  • An unbounded array used as a log or live feed — technically not a leak, behaves exactly like one, fixed by capping length.
  • An IntersectionObserver/ResizeObserver/MutationObserver never disconnected.
  • A store or cache keyed by route accumulating every page ever visited (a common gcTime: Infinity misconfiguration).
  • Chart, map and editor libraries that need an explicit .destroy(); React removing the container does not release their canvases and listeners.
05

Two engineers disagree: one wants useEffect for data fetching, the other wants React Query. Adjudicate.

This is not a close call, and the reasoning is what earns the point. A raw useEffect fetch must solve, by hand and per call site: cancellation on unmount, the race where a slow earlier response overwrites a newer one, loading and error state, retries with backoff, deduplication of the same request from three components, caching and revalidation, refetch on focus and reconnect, and pagination state. Each is a real bug you will ship, and by the sixth call site you have written a worse React Query.

jsx
// the minimum useEffect version that is merely CORRECT — still no cache, dedup or retry
useEffect(() => {
  const c = new AbortController();
  let alive = true;
  setState({ loading: true });
  fetch(url, { signal: c.signal })
    .then(r => { if (!r.ok) throw new HttpError(r.status); return r.json(); })
    .then(d => { if (alive) setState({ data: d, loading: false }); })
    .catch(e => { if (alive && e.name !== "AbortError") setState({ error: e, loading: false }); });
  return () => { alive = false; c.abort(); };
}, [url]);

Raise the counter-argument yourself: a library is a dependency with a bundle cost and a learning curve, and for an app with three fetches it is over-engineering. And if your framework already has a data layer — Server Components with fetch caching, Remix loaders — use that instead of stacking a client cache on top.

06

Your bundle is 1.8 MB gzipped and the team wants to switch to a lighter framework. What do you do?

Almost certainly do not switch frameworks. It is a multi-quarter project with a high failure rate, and the framework is rarely the bulk of the bundle: React plus ReactDOM is roughly 45 KB gzipped. 1.8 MB means 1.75 MB of something else, and ten minutes of measurement will name it.

  1. Analyse before deciding. @next/bundle-analyzer, rollup-plugin-visualizer or source-map-explorer produces a treemap. Bring it to the discussion — the conversation changes completely once everyone can see that one date library and one icon set are 600 KB.
  2. Expect to find: moment.js with every locale; lodash imported wholesale; an icon barrel file defeating tree-shaking; two competing UI or chart libraries because two teams chose separately; a PDF or spreadsheet library loaded globally for one route; polyfills for browsers you dropped; a duplicated dependency at two versions.
  3. Fix by bytes-per-hour-of-work: replace the biggest offender, route-level code splitting, React.lazy/next/dynamic for heavy widgets, per-function imports, a modern build target so you stop transpiling to ES5.
  4. Set a CI budget, because it will regress otherwise.
  5. Only then, if the framework is genuinely the constraint — which is real for landing pages and embeddable widgets where 45 KB matters — consider Preact for that surface only.