Interview & Career • 10 min read • 06/01/2026

Frontend Interview: Answering Questions About Render Performance and Core Web Vitals

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Monitors displaying charts
Photo: Federal Bureau of Investigation · Public domain

The question that comes up most in interviews: “How do you know what’s slow?” Answering “I optimized performance” says nothing.

Someone who can say “LCP was 3.8s, after the fix it’s under 1.2s, and here’s how I measured it” is in a different league. The examples below come from an analytics dashboard with ~150 charts at Cognisian.

Unnecessary Re-renders Come From Three Places

The classic opener. Answering “because props changed” is a miss. The three most common causes:

  1. A new reference on every render: objects, arrays and functions created inline in JSX (<Chart config={{ theme: 'dark' }} />) produce a new reference each time the parent renders. That makes React.memo on the child useless, because the reference comparison always fails.
  2. A context change that spreads widely: a Context Provider wraps too many components, and when the context value changes (even a small part of it), the whole subtree re-renders, including components that don’t use the part that changed.
  3. State at the wrong level: state that only a small child needs lives in a big parent, so every state change re-renders the entire subtree, including the unrelated parts.

A convincing answer comes with the matching fix: useMemo/useCallback for case 1, splitting the context into several smaller ones by domain for case 2, and pushing state down as close as possible to where it’s used for case 3.

Three causes, three fixes, side by side:

flowchart TD
    R["Unnecessary re-render"] --> C1["1. New reference on every render<br/>objects, arrays, functions created inline in JSX"]
    R --> C2["2. Context change spreads widely<br/>Provider wraps too many components"]
    R --> C3["3. State at the wrong level<br/>state in a big parent, used only in a small child"]
    C1 --> F1["useMemo / useCallback"]
    C2 --> F2["Split into several small contexts by domain"]
    C3 --> F3["Push state down closest to where it's used"]

Measuring Core Web Vitals the Right Way

Plenty of people can recite the definitions: LCP measures how fast the largest element loads, CLS measures layout shift, INP measures interaction response delay. Measuring them in the real world is the hard part. A good answer should mention:

  • Lab data (Lighthouse, WebPageTest) for active debugging during development, since you control network and CPU conditions.
  • Field data (Chrome UX Report, or the web-vitals package sending RUM to analytics) to see what real users experience. Lab data doesn’t reflect their actual devices and networks.
import { onLCP, onINP, onCLS } from "web-vitals";

onLCP((metric) => sendToAnalytics("LCP", metric.value));
onINP((metric) => sendToAnalytics("INP", metric.value));
onCLS((metric) => sendToAnalytics("CLS", metric.value));

Before/After Numbers: the ~150-Chart Dashboard at Cognisian

“I optimized performance” is less convincing than a before/after pair. The example I like to use: the analytics dashboard at Cognisian started with an LCP of ~3.8s, because all ~150 charts fetched and rendered the moment the page mounted. What we did:

  • Used IntersectionObserver so a chart only renders when you scroll near the viewport (virtualizing what’s out of sight).
  • Split the fetch into one query per chart instead of one giant request bundling everything, so the first chart (above the fold) gets its data much sooner.
  • Used skeleton loading at the exact size to avoid CLS when the real chart finishes loading.

Result: LCP dropped from 3.8s to under 1.2s. CLS went to nearly 0, because the skeleton holds the exact space before the real chart renders.

useMemo/useCallback: When You Really Need Them

This is where the questioning gets deepest. It’s also where “memorized” and “actually understands” show.

useMemo/useCallback aren’t free. They also cost a dependency array comparison on every render. Only use them when:

  • The computed value is genuinely expensive (say, processing an array of thousands of items), or
  • The reference needs to be stable to avoid re-rendering a child wrapped in React.memo, or to avoid re-triggering a useEffect that depends on it.

Using useMemo for every tiny computation (like a + b) backfires: the cost of comparing dependencies is higher than just recomputing that simple value.

The Trap: Optimizing Before Measuring

The common wrong answer is to list a pile of optimization techniques (memoize everything, virtualize every list, code-split every route) without mentioning the mandatory first step: measure before you optimize.

A good interviewer will push back: “How do you know that’s the bottleneck?” The best answer always starts with the React DevTools Profiler or the Chrome Performance tab, to pin down exactly which component or function is eating time, before applying any technique.

Optimizing without measuring usually ends with useMemo sprinkled everywhere, harder-to-read code, and no real performance gain, because the actual bottleneck is somewhere else, such as the network waterfall rather than re-renders.

An Answer Framework That Works for Any Performance Question

State the right technical cause, how to measure it to confirm, the fix, then the before/after numbers. Four steps. It fits almost any performance question in a frontend interview round.

Follow the order and the answer carries its own weight:

flowchart LR
    A["1. State the right technical cause"] --> B["2. How to measure to confirm"]
    B --> C["3. The fix"]
    C --> D["4. Before/after numbers"]

People who’ve memorized answers tend to jump straight to step 3. Step 2 is what sets you apart.

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.