Skip to content
Frontend

10 Frontend Performance Interview Questions and Answers

This focused guide turns RecallDeck’s curated Frontend Performance material into 10 interview-ready questions. Answer each one before opening the explanation, then use the examples and edge cases to repair anything vague or incomplete.

10 min read10 detailed answersReviewed Aug 24, 2026
What to remember

Connect every framework answer to browser behavior, accessibility, performance, or user-visible state; that is where senior frontend judgment becomes visible.

Question set

10 detailed answers

01

What is the Critical Rendering Path and which resources block it?

Short answer: The Critical Rendering Path is the browser's sequence from receiving HTML to the first painted pixel: DOM + CSSOM → render tree → layout → paint → composite. Synchronous CSS and blocking JS delay this path.

In depth:

  1. DOM — the browser parses HTML into a tree of nodes.
  2. CSSOM — the style object model is built in parallel; CSS blocks rendering because styles can't be computed without it.
  3. Render tree — DOM and CSSOM merge; display: none nodes are dropped.
  4. Layout (reflow) — element geometry and positions are computed.
  5. Paint — nodes are rasterized into pixel layers.
  6. Composite — layers are assembled on screen (often on the GPU).
HTML ─► DOM ─┐
             ├─► Render Tree ─► Layout ─► Paint ─► Composite ─► screen
CSS  ─► CSSOM┘
  ▲ blocks rendering        ▲ <script> without defer/async

⚠️ Common mistake: assuming JS never interferes with rendering. A synchronous <script> in the <head> halts HTML parsing and DOM construction — use defer or move scripts to the bottom.

02

What's the difference between reflow, repaint, and composite, and which CSS properties are cheap to animate?

Short answer: Reflow (layout) recomputes geometry, repaint redraws pixels without geometry changes, and composite merely reassembles already-painted layers. Only transform and opacity are cheap to animate — they run on the composite stage, skipping layout and paint.

In depth:

  1. Reflow — the costliest: changes size/position (width, top, margin, adding nodes) and cascades to descendants and siblings.
  2. Repaint — redraw without geometry shifts (color, background, visibility).
  3. Composite — the GPU merges ready layers; layout and paint don't run.
Property Stage Cost
width, top, margin layout → paint → composite high
color, background paint → composite medium
transform, opacity composite low

⚠️ Common mistake: animating left/top or width/height instead of transform: translate() / scale() — that triggers a reflow every frame and tanks FPS.

03

What are the Core Web Vitals (LCP, INP, CLS), what are their thresholds, and how do you improve them?

Short answer: Core Web Vitals are Google's three key metrics: LCP (loading), INP (responsiveness), and CLS (visual stability). As of March 2024, INP officially replaced FID as the responsiveness metric.

In depth:

  1. LCP (Largest Contentful Paint) — time to render the largest element. Improve with LCP-image prioritization (fetchpriority="high", preload), fast TTFB, and removing render-blocking resources.
  2. INP (Interaction to Next Paint) — latency from interaction to next paint across all interactions in a session. Improve by breaking up long tasks, requestIdleCallback, and shipping less JS.
  3. CLS (Cumulative Layout Shift) — total layout movement. Improve by reserving space for images (width/height), font-display, and avoiding insertions above content.
Metric Measures Good Poor
LCP loading ≤ 2.5 s > 4.0 s
INP responsiveness ≤ 200 ms > 500 ms
CLS stability ≤ 0.1 > 0.25

⚠️ Common mistake: citing FID as a current metric. FID was removed in March 2024 — responsiveness is now measured by INP, which accounts for the whole interaction, not just the first input delay.

04

How do script defer and async differ, and what are preload, prefetch, and preconnect for?

Short answer: async loads a script in parallel and runs it as soon as it's ready (order not guaranteed); defer loads in parallel but executes in order after DOM parsing. preload is a high-priority fetch for the current page, prefetch is a background fetch for future navigations, and preconnect opens a connection early.

In depth:

  1. defer — for scripts that depend on the DOM and on each other; preserves order, doesn't block parsing.
  2. async — for independent scripts (analytics); runs the moment it loads.
  3. preload — "need it now": a font, the LCP image, critical CSS.
  4. prefetch — "need it later": next-navigation resources, low priority.
  5. preconnect — perform DNS + TLS handshake to a third-party origin ahead of time (CDN, fonts).
<script src="app.js" defer></script>
<script src="analytics.js" async></script>

<link rel="preload" href="hero.avif" as="image" fetchpriority="high">
<link rel="prefetch" href="/next-page.js">
<link rel="preconnect" href="https://cdn.example.com">

<img src="below-fold.jpg" loading="lazy" alt="...">

⚠️ Common mistake: putting async on scripts whose load order matters — "first one wins" execution breaks dependencies. Use defer for those.

05

How do you reduce bundle size: code splitting, dynamic import(), and tree shaking?

Short answer: Ship less JS upfront: split the bundle by route/component via dynamic import() (code splitting), drop dead code via tree shaking (which requires ESM modules), and inspect the bundle's contents with a visualizer.

In depth:

  1. Code splitting — break code into chunks loaded on demand; cuts the initial payload.
  2. Dynamic import() — lazily load a module/component at the moment it's needed (route, modal).
  3. Tree shaking — the bundler drops unused exports. Works only with import/export (ESM), not CommonJS require, and breaks on side effects — "sideEffects": false helps.
  4. Analysisrollup-plugin-visualizer / webpack-bundle-analyzer reveal what bloats the bundle (often heavy date/icon libraries).
// Static import — lands in the main bundle
import heavyChart from "chart-lib";

// Dynamic — split into its own chunk, loaded on demand
const Chart = React.lazy(() => import("./Chart"));

button.addEventListener("click", async () => {
  const { renderModal } = await import("./modal.js");
  renderModal();
});

⚠️ Common mistake: expecting tree shaking when importing a whole library (import _ from "lodash") or from a CommonJS build — import narrowly instead (import debounce from "lodash/debounce").

06

How does browser caching work: Cache-Control, ETag, hashed filenames, and Service Worker?

Short answer: The HTTP cache is driven by Cache-Control headers (how long and how to cache) and ETag/Last-Modified validators (freshness checks via 304). A hash in the filename (app.a1b2c3.js) provides cache busting, and a Service Worker adds a programmable cache for offline use.

In depth:

  1. Cache-Controlmax-age sets the TTL; immutable disables revalidation; no-cache means "cache but revalidate".
  2. ETag / Last-Modified — once the TTL expires the browser sends If-None-Match; the server replies 304 Not Modified with no body, saving bandwidth.
  3. Hashed filenames — content changes → name changes → the old cache is ignored. So assets are served with max-age=31536000, immutable, while HTML uses no-cache.
  4. Service Worker — intercepts requests (fetch), implements strategies (cache-first, stale-while-revalidate), and works offline.
Cache-Control: public, max-age=31536000, immutable   # app.a1b2c3.js
Cache-Control: no-cache                               # index.html
ETag: "5d8c72a..."

⚠️ Common mistake: setting a long max-age on un-hashed HTML — users get stuck on a stale version. Hash your assets and keep the entry-point HTML on no-cache.

07

How do you render large lists efficiently, and what is virtualization (windowing)?

Short answer: Virtualization (windowing) renders only the rows visible in the viewport plus a small buffer, not all thousands of DOM nodes. This keeps node count and layout/paint time constant regardless of list length.

In depth:

  1. The problem — 10,000 rows = 10,000 DOM nodes: huge layout, slow paint, bloated memory, janky scrolling.
  2. The idea — the container sets the full height (itemCount × itemHeight), but only visible rows exist in the DOM, positioned via transform: translateY.
  3. The window — on scroll the visible index range is recomputed; a small overscan buffer is rendered beyond the viewport to keep scrolling smooth.
  4. Toolsreact-window / @tanstack/virtual for React; dynamic row heights require estimation and measurement.
  Real list                  Virtualized DOM
┌───────────────┐          ┌───────────────┐ ← scroll container
│ row 0         │          │ row 142       │  (visible window
│ ...           │          │ row 143       │   + overscan only)
│ row 9999      │          │ row 144       │
└───────────────┘          └───────────────┘
  10000 nodes                ~12 nodes in DOM

⚠️ Common mistake: trying to "fix" large-list jank with React.memo and pagination while leaving every node in the DOM. The bottleneck is DOM size and layout; virtualization is what solves it.

08

How do you optimize images and fonts: WebP/AVIF, srcset/sizes, and font-display?

Short answer: Use modern formats (AVIF/WebP over JPEG/PNG), serve to the screen size with srcset/sizes (responsive images), and set font-display: swap for fonts to avoid FOIT (invisible text), plus preload for critical fonts.

In depth:

  1. Formats — AVIF compresses the hardest, WebP has broad support; <picture> provides a JPEG fallback.
  2. Responsive imagessrcset lists width variants, sizes tells the browser the displayed size so it picks the smallest sufficient file.
  3. font-displayswap shows the system font immediately and swaps in the loaded one (FOUT instead of FOIT); optional is leaner on slow networks.
  4. Stability — set width/height or aspect-ratio to prevent layout shift (CLS).
Format Transparency Compression When
AVIF yes best hero, photos
WebP yes good default + fallback
SVG yes vector icons, logos
<picture>
  <source type="image/avif" srcset="hero-480.avif 480w, hero-960.avif 960w" sizes="(max-width:600px) 480px, 960px">
  <img src="hero.jpg" width="960" height="540" alt="...">
</picture>
@font-face { font-family: Inter; src: url(inter.woff2) format("woff2"); font-display: swap; }

⚠️ Common mistake: shipping one huge JPEG for every screen. Without srcset, mobile devices download the desktop version, inflating LCP and bandwidth.

09

How do you measure performance: Lighthouse, the Performance API / web-vitals, and lab vs field (RUM)?

Short answer: Lighthouse and DevTools give lab data — a synthetic run under controlled conditions. The Performance API and the web-vitals library collect field (RUM) data from real users. Lab is for debugging; field is for judging the real experience.

In depth:

  1. Lab — Lighthouse, WebPageTest: reproducible, with fixed network/CPU throttling; great for catching regressions in CI, but doesn't reflect the diversity of real devices.
  2. Field / RUM — data from actual users (CrUX, web-vitals): reflects real networks, devices, geography. INP and CLS are only measured correctly in the field, across the whole session.
  3. Performance APIPerformanceObserver, performance.getEntriesByType('navigation'), paint — low-level access to timings.
  4. web-vitals — a wrapper for reporting LCP/INP/CLS to analytics.
import { onLCP, onINP, onCLS } from "web-vitals";

function send(metric) {
  navigator.sendBeacon("/rum", JSON.stringify(metric));
}
onLCP(send);
onINP(send);
onCLS(send);
Lab Field (RUM)
Source synthetic real users
Tool Lighthouse web-vitals / CrUX
Use debugging, CI real experience

⚠️ Common mistake: optimizing only for a 100/100 Lighthouse score. A lab run won't reveal real INP — users on low-end phones may see completely different numbers.

10

How do you improve runtime scroll performance: requestAnimationFrame, debounce/throttle, and passive listeners?

Short answer: Sync visual updates to frames with requestAnimationFrame, rate-limit scroll/resize handlers with throttle/debounce, mark listeners { passive: true } so they don't block scrolling, and break up long tasks (>50 ms) so INP doesn't suffer.

In depth:

  1. requestAnimationFrame — runs the callback before the next repaint (~60 fps), avoiding redundant layout and torn frames.
  2. throttle / debounce — throttle caps call frequency (scroll), debounce waits for a pause (search, resize).
  3. passive: true — promises not to call preventDefault, so the browser scrolls without waiting for the handler.
  4. Long tasks — tasks longer than 50 ms block the main thread and hurt INP; split them (scheduler.yield, setTimeout).
let ticking = false;
window.addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    updateHeader(window.scrollY); // read/write layout once per frame
    ticking = false;
  });
}, { passive: true });

⚠️ Common mistake: doing heavy work and layout reads (offsetTop) directly in a scroll handler without rAF and throttling — this forces a reflow on every event and kills smoothness.

Source notes

References and review policy

RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.

From reading to recall

Practice the full interview loop.

RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.

Start studying

Keep going

RecallDeck Interview Library

Detailed answers from the same curated interview deck, organized for search, study, and durable recall.

RSS