28 min read

FE-09 — Senior Frontend Scenarios

The System Design Playbook for Frontend Interviews

Part of The Production Frontend Engineer series. Prerequisites: All of FE-00 through FE-08. This chapter assumes you have read every previous book. Do not read this chapter first. Every answer here requires reasoning that only makes sense after building the foundations.


Opening

This chapter is not a list of answers to memorise. It is a demonstration of how to reason about any frontend problem from first principles under pressure, in real time, in front of an interviewer. The scenarios here are drawn from actual senior frontend interviews at product companies. Each one has a worked answer — not a script to reproduce, but an example of the reasoning process an interviewer is watching for. The interviewer is not checking whether you know the answer. They are checking whether you know how to think. After reading each scenario and its answer, close the book and answer it yourself from scratch. If you can derive the answer without reading it again, you own the concept. If you cannot, re-read the relevant prerequisite chapter, not this one.


How Senior Frontend Interviews Work

A senior frontend system design question is not a trivia question. It is a collaborative problem-solving exercise. The interviewer wants to see:

Structured thinking before any solution: you ask clarifying questions, define constraints, and establish what "correct" means before proposing anything. Engineers who jump straight to solutions without understanding the problem space fail these rounds even if their solution is technically correct.

Trade-off reasoning: every architectural decision has a cost. "Use code splitting" is not an answer. "Use route-level code splitting because the payment page's PDF library is 340KB gzipped and adds 2.3 seconds of parse time on mobile, and the vast majority of users never reach the payment page in a given session" is an answer.

Failure mode awareness: good systems are designed around their failure modes, not their happy paths. The interviewer will probe: "what happens if the WebSocket drops?" "what happens if the user's network is slow?" "what if this data grows to 10 million rows?"

Production thinking: lab conditions, not demo conditions. The question is not "does this work?" but "does this work for 100,000 users with varying devices and networks?"


The Six Questions to Ask Before Answering Any Scenario

These six questions transform a vague scenario into a constrained engineering problem. Ask them every time, in roughly this order.

  1. What is the scale? Users, data volume, update frequency. A solution correct at 100 users is wrong at 1,000,000.
  2. Who are the users and what devices do they use? Consumer users on mobile require different solutions than enterprise users on desktop.
  3. What is the freshness requirement? Can data be 5 minutes stale? 5 seconds? Real-time?
  4. What are the failure modes I need to handle? Network drops, server outages, bad data.
  5. What are the hard constraints? SEO requirement? Specific performance budget? Team size limits the maintainability of solutions?
  6. What does success look like? What specific metrics confirm the solution is working?

You do not always get answers to all six. But asking them demonstrates the engineering mindset the interviewer is looking for.


Scenario 1: "Design the frontend architecture for a real-time collaborative document editor (like Notion or Google Docs)"

The Questions to Ask

Before anything: "How many simultaneous collaborators per document?" (2-3 vs 50 changes the conflict resolution strategy fundamentally.) "Does the app require offline support?" (Changes the entire sync architecture.) "What is the expected document complexity — plain text only, or rich text with embeds?" (Rich text requires an entirely different data model.)

Assumed answers: up to 10 simultaneous collaborators, offline support required, rich text with basic formatting.

The Architecture Answer

The core problem in collaborative editing is not real-time sync — it is conflict resolution. Two users typing at position 47 in the same document simultaneously produce conflicting operations. The architecture must resolve these conflicts in a way that is predictable, converges to a consistent state, and does not feel broken to either user.

The data model is the foundation. A plain string cannot support conflict-free concurrent editing. The document must be represented as a data structure that supports Operational Transformation (OT) or Conflict-Free Replicated Data Types (CRDTs). Modern editors use CRDTs because they do not require a central server to sequence operations — conflicts are resolved algorithmically by the data structure itself. Libraries like Yjs and Automerge implement CRDTs for rich text. The frontend team should not implement this from scratch.

The transport layer uses WebSockets for real-time synchronisation. Every character the user types produces an operation (insert at position X, delete range Y-Z). The operation is immediately applied to the local document (optimistic update — the user sees their own changes instantly), and simultaneously sent to a WebSocket server that broadcasts it to all other connected clients.

When a client receives an operation from another client, it applies it to its local document using the CRDT's merge algorithm. The CRDT guarantees that all clients converge to the same state regardless of the order operations arrive — this is the mathematical property that makes collaborative editing work without a locking model.

Offline support requires a local persistent store (IndexedDB) that buffers operations while the user is disconnected. When connectivity is restored, the client replays buffered operations against the server's current state. The CRDT merge algorithm handles this case the same way it handles concurrent operations — the offline user's changes and other users' changes are merged automatically.

The rendering architecture matters for performance. A rich text document modelled as a React component tree with one component per paragraph — and React re-rendering the entire tree on every keystroke — will be unacceptably slow at document sizes beyond a few thousand words. The correct approach is to use a purpose-built rich text rendering library (ProseMirror, TipTap over ProseMirror, or Slate.js) that manages its own DOM mutations and bypasses React's reconciler for the hot path. React manages the application shell (toolbar, sidebar, presence indicators). The editor content is managed directly by the text library.

Presence (showing other users' cursors) is a separate low-latency stream. Cursor positions are broadcast via WebSocket at high frequency (every cursor move). They are rendered as absolutely-positioned elements overlaid on the document, updated in-place via direct DOM manipulation — not via React state — to avoid triggering React re-renders on every cursor movement from every collaborator.

What the Interviewer Is Listening For

The CRDT vs OT decision demonstrates you know the data model is the hard problem, not the networking. The decision to use a specialised editor library rather than React demonstrates you know when to bypass React's rendering model. The offline/sync architecture demonstrates production thinking.


Scenario 2: "Your dashboard's LCP is 5.8 seconds in field data. Walk me through your investigation."

The Right Opening Move

Do not immediately say "I would optimise the images." Instead: "Before touching anything, I want to understand where the time is going. LCP can be slow for four distinct reasons — high TTFB, render-blocking resources, late resource discovery, or slow resource load. The fix for each is different, and doing the wrong fix wastes time."

The Structured Investigation

First, establish context. Check whether lab and field data agree. Open DevTools with CPU throttled 4× and Slow 4G. Run Lighthouse. If Lighthouse shows 2.1 seconds but field data shows 5.8 seconds, the gap is explained by the difference between lab conditions (your MacBook, your network, your location) and real users (mobile Android, 3G, Mumbai). The field data is the true number. Now apply the same throttle conditions in DevTools to reproduce the field experience in the lab for debugging.

Step 1: Identify the LCP element. Open the Performance tab, record a throttled page load, find the LCP marker, note the element. For a dashboard, the LCP element is typically either a large data table (because its text content is the largest element) or a chart that renders a background image.

Step 2: Check TTFB. Network tab, HTML document, Timing section, "Waiting for server response." If TTFB is above 800ms, the server is the primary bottleneck. Fix this before anything else — a 1.2-second TTFB means no other client-side optimisation can bring LCP below 1.2 seconds. Common causes for a SaaS dashboard: expensive database query on initial page render, cold serverless function start, no database connection pooling.

Step 3: Check whether the LCP element is in the initial HTML. View Page Source (not DevTools Elements — that shows the post-JavaScript DOM). Search for the LCP element. If a dashboard chart is the LCP element and it is rendered by a JavaScript component that fetches its own data, it is not in the initial HTML. The browser cannot start rendering it until JavaScript has loaded, executed, mounted the component, made an API call, received the response, and rendered the result. This is a 2-4 second chain on mobile. Fix: either server-render the initial data into the HTML (Next.js Server Components with direct DB access), or choose a simpler element as the LCP element (a server-rendered heading rather than a data-dependent chart).

Step 4: Check for render-blocking resources. Network tab, filter CSS, check for large stylesheets loading synchronously in <head>. A 400KB CSS file blocks rendering until it is parsed. Fix: split critical CSS (styles needed for above-fold content) from non-critical. Inline critical CSS in a <style> tag, load the rest asynchronously with <link rel="preload" as="style"> and an onload handler.

Step 5: Check LCP resource timing. If the LCP element is an image and it is in the initial HTML, click it in the Network tab. Check when the request started (waterfall position), and how long the download took. If the request starts at 2.3 seconds into the load, it is discovered late — add a <link rel="preload"> in <head>. If the download takes 1.8 seconds, the image is too large — compress, convert to AVIF/WebP, serve from a CDN edge close to the user.

What the Interviewer Is Listening For

The structured pipeline-first approach (TTFB → discovery → resource load) rather than jumping to "optimise images." The distinction between View Page Source and DevTools Elements. Knowing that a data-dependent client-rendered component cannot be the LCP element on a performant page.


Scenario 3: "Design the frontend for a real-time telemetry dashboard showing 50,000 data points updating 10 times per second"

The Questions to Ask

"What is the rendering fidelity requirement — does every data point need to be visualised individually, or is aggregation acceptable?" "What are the target devices — desktop-only or mobile too?" "Is there a specific latency requirement for how fresh the display must be?"

Assumed answers: individual data points must be visible, desktop-only, display must be within 500ms of actual data.

The Architecture Answer

The first and non-negotiable decision: the DOM cannot be used for rendering this data. 50,000 DOM nodes update 10 times per second means 500,000 DOM mutations per second. Even with optimal batching, the browser's layout and paint pipeline cannot process this volume. The frame budget is 16ms per frame. At 10 updates per second, each update batch has 100ms to complete — but with 50,000 nodes, a single full-tree layout pass takes hundreds of milliseconds on any hardware.

The rendering target must be Canvas or WebGL. For 50,000 data points updating at 10Hz, Canvas 2D is marginal — it is CPU-bound and requires redrawing affected regions on every update. WebGL is the correct choice: the GPU's parallelism (thousands of cores processing pixels simultaneously) handles this volume comfortably.

The data architecture must separate the data pipeline from the rendering pipeline. WebSocket receives updates at 10Hz. Updates are buffered in a data store (a simple JavaScript typed array or a custom ring buffer in an ArrayBuffer) — not in React state. React state triggers React re-renders. Re-rendering a 50,000-point chart 10 times per second through React's reconciler is impossible.

The WebGL renderer runs on a requestAnimationFrame loop, completely independent from React. On each frame tick, it reads the current data from the typed array buffer and redraws the WebGL scene. The frame loop runs at 60fps (every 16ms) even if data only arrives at 10Hz — the display shows the most recent data without redraws being tied to data arrival.

React manages the UI chrome: controls, filter dropdowns, legend, settings. These components read from React state normally and re-render when settings change. The WebGL canvas is a <canvas> element managed by a ref — React treats it as an opaque DOM node and never touches its contents.

The WebGL architecture: each data series is a vertex buffer in GPU memory. An update operation modifies the relevant vertices in the buffer (via gl.bufferSubData — partial GPU buffer update, not a full re-upload). The vertex shader transforms buffer coordinates to screen coordinates. The fragment shader assigns colours. One draw call per data series renders all 50,000 points in a single GPU pass.

For the network layer: at 10Hz with potentially large payloads, binary encoding (Protocol Buffers or a custom binary format) is significantly more efficient than JSON. A 50,000-point JSON array update might be 2-5MB per update, which at 10Hz is 20-50MB/s of data — saturating most network connections. Binary-encoded delta updates (only changed values, not full snapshots) might be 50-200KB per update — feasible on a normal connection.

What the Interviewer Is Listening For

The immediate identification that DOM is wrong. The separation between data pipeline and render pipeline. The WebGL architecture. The binary protocol consideration for network efficiency. The requestAnimationFrame loop independent from React.


Scenario 4: "The app feels laggy on scroll. Walk me through diagnosing and fixing it."

The Right Opening

"Laggy scroll" has several distinct causes, and the fix depends entirely on which one is occurring. I need to identify whether the lag is: main thread blocking during scroll, excessive paint work during scroll, too many DOM nodes, or scroll event handler cost.

The Structured Approach

Step 1: Record the scroll in the Performance panel. Interactions track: is there input delay (scroll events waiting for the main thread)? Main thread track: are there purple (Layout) or green (Paint) bars during scroll? Timeline track: are frames being dropped (grey bars)?

Step 2: Identify the category of problem.

Paint flashing (enable in Rendering panel): if large areas of the page are showing green overlays during scroll, something is triggering repaints. Common cause: box-shadow or background-color on elements with :hover states that trigger as the user scrolls past. CSS will-change: transform on the problematic elements promotes them to their own compositor layers, eliminating the repaint. Or: remove expensive CSS properties from scroll-exposed elements.

Long tasks during scroll (visible in the Main thread track): if there are 50ms+ tasks firing during scroll, a scroll event handler is doing too much work. Apply throttling (FE-02 machine coding catalog pattern). Move the work to a Web Worker if it is CPU-bound and does not need DOM access.

Forced synchronous layout in scroll handler: the most common cause of "laggy" scroll. A scroll handler reads scrollTop or getBoundingClientRect to determine what is visible, then updates DOM elements. This alternating read-write pattern triggers forced synchronous layout on every scroll event. Fix: cache all read values before the scroll handler, or use IntersectionObserver (which does not force layout) instead of scroll events.

Too many DOM nodes: a page with 5,000+ nodes is slower to paint and layout than a page with 500 nodes. If the laggy scroll is on a long list, the fix is virtualisation — rendering only visible rows (FE-00 and FE-02's virtualisation section). The virtualised list maintains 20-50 DOM nodes regardless of list length.

position: fixed or position: sticky elements: these create stacking contexts that require the compositor to handle them specially. On some devices, many fixed/sticky elements cause compositor-side performance issues. Audit and remove unnecessary fixed positioning.

What the Interviewer Is Listening For

The rejection of "it's just slow" in favour of specific categorisation. The use of the Rendering panel's Paint Flashing. The identification of forced synchronous layout as a specific and diagnosable cause. The distinction between compositor-side (transform, opacity) and main-thread-side (layout, paint) scroll costs.


Scenario 5: "Design a frontend system that works offline and syncs when connectivity is restored"

The Questions to Ask

"What type of data needs to work offline — read-only content, or read-write mutations?" "How long might the user be offline — minutes or days?" "What is the conflict resolution requirement when multiple devices are used while offline?"

Assumed answers: read-write mutations, up to 24 hours offline, last-write-wins conflict resolution is acceptable.

The Architecture Answer

The architecture has three layers: persistent local storage, a sync engine, and the UI state management.

Local storage: IndexedDB is the only browser storage option suitable for structured, queryable, large-volume offline data. localStorage is synchronous (blocks the main thread) and limited to 5-10MB. IndexedDB is asynchronous, can store gigabytes, supports indexed queries, and is available in Service Workers. Use idb (a Promise wrapper) or Dexie.js (a full-featured ORM) rather than the raw IndexedDB API.

Data model for sync: every mutation (create, update, delete) is stored as an operation with: a UUID, the entity it affects, the operation type, the payload, a timestamp, and a synced: boolean flag. The local database always reflects the user's current view of the data (including unsynced operations). The server reflects the canonical truth.

javascript
// Operation log — the core of the sync engine
{
  id: 'op-uuid-1234',
  entityType: 'todo',
  entityId: 'todo-uuid-5678',
  operation: 'update',
  payload: { completed: true },
  timestamp: 1706789234567,
  synced: false
}

Sync engine: on every action the user takes (create a todo, update a field, delete an item), two things happen simultaneously: (1) the operation is written to the local IndexedDB operation log and applied immediately to the local view (optimistic), and (2) if online, the operation is sent to the server via API.

When the user goes offline, operations continue being written to the log with synced: false. When connectivity is restored, the sync engine reads all unsynced operations, sends them to the server in order, and marks them as synced on successful response.

Conflict resolution: with last-write-wins, the server compares the operation's timestamp to the entity's lastModifiedAt. If the operation's timestamp is newer, it is applied. If older (another device updated the entity while this device was offline), the server's version wins and the client is corrected on next read.

Service Worker integration: the Service Worker intercepts all API calls. If online, the call goes to the network and the response is cached. If offline, the Service Worker queues the mutation in the operation log (which it can access because Service Workers can use IndexedDB) and returns a synthetic success response. The user sees no error. The background sync happens via the Background Sync API when connectivity is restored, or manually when the user opens the app with connectivity.

UI: the React application always reads from IndexedDB, never from server responses directly. Server responses update IndexedDB, and the UI is reactive to IndexedDB changes. The distinction between "local-only" and "synced" data is surfaced in the UI as a subtle indicator (a small cloud icon, a "saving..." label) so the user knows their data is committed locally but not yet synced.

What the Interviewer Is Listening For

The operation log pattern (not naive local state). The distinction between IndexedDB and other storage options. The optimistic apply-then-sync pattern. The Service Worker integration for transparent offline mutation handling.


Scenario 6: "Design a system for streaming AI chat responses in a production application"

The Questions to Ask

"What model and API provider?" "Is markdown rendering required?" "What is the expected concurrent user count?" "Are there latency requirements?"

Assumed answers: Gemini or OpenAI-compatible API, markdown rendering required, up to 10,000 concurrent users, first token must appear within 1 second.

The Architecture Answer

The transport layer: Server-Sent Events (SSE) is the correct choice over WebSockets. AI response streaming is one-directional — server to client. WebSockets provide bidirectional communication at higher infrastructure cost. SSE is a simple HTTP connection that stays open, with the server writing data: ...\n\n lines as tokens arrive. The browser's native EventSource API handles reconnection automatically. SSE works over HTTP/2, allowing efficient multiplexing.

The API architecture: the browser connects to your backend (a Next.js Route Handler or an API endpoint), not directly to the AI provider. Your backend holds the AI API key, enforces rate limiting, validates authentication, and can implement caching. Never expose AI API keys to the browser.

typescript
// app/api/chat/route.ts
export async function POST(request: Request) {
  const session = await auth()
  if (!session) return new Response('Unauthorized', { status: 401 })

  const { messages } = await request.json()

  const stream = await openai.chat.completions.create({
    model: 'gpt-4',
    messages,
    stream: true,
  })

  // Convert the AI provider's stream to a ReadableStream the browser can consume
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        const token = chunk.choices[0]?.delta?.content || ''
        if (token) {
          controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ token })}\n\n`))
        }
      }
      controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
      controller.close()
    }
  })

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    }
  })
}

The client rendering: received tokens must update the UI without triggering expensive React re-renders on every token (at ~30 tokens per second, that is 30 setState calls per second triggering React reconciliation). The correct pattern: accumulate tokens in a useRef (no re-render), flush to state via requestAnimationFrame (one re-render per frame, at 60fps). The user sees smooth streaming; React renders at most 60 times per second regardless of token rate.

typescript
function useStreamingMessage(url: string) {
  const [displayText, setDisplayText] = useState('')
  const bufferRef = useRef('')

  useEffect(() => {
    const source = new EventSource(url)
    let rafId: number

    source.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data === '[DONE]') { source.close(); return }
      bufferRef.current += data.token

      cancelAnimationFrame(rafId)
      rafId = requestAnimationFrame(() => {
        setDisplayText(bufferRef.current) // one setState per frame maximum
      })
    }

    return () => { source.close(); cancelAnimationFrame(rafId) }
  }, [url])

  return displayText
}

Markdown rendering during streaming: standard Markdown parsers expect complete input. Partial markdown (**bo mid-bold) will render incorrectly. Solutions: buffer until paragraph boundaries (render complete paragraphs only), or use a streaming-aware parser like marked with its walkTokens feature. For code blocks specifically, buffer the entire block and render it when the closing triple-backtick arrives.

What the Interviewer Is Listening For

The SSE over WebSocket decision with justification. The backend-proxied architecture for API key security. The token buffering with requestAnimationFrame to decouple token arrival from React renders. The incomplete markdown problem and its solution.


Scenario 7: "A critical bug fix was deployed 2 hours ago but 20% of users are still seeing the old version. Debug it."

This is a caching and deployment debugging question. Structure the investigation by cache layer, from outermost to innermost.

Layer 1: CDN cache. Check your CDN dashboard. Is the index.html document cached at any CDN edge with a max-age greater than zero? If yes, that edge is serving stale HTML to all requests it handles until the cache expires. Fix: purge index.html from all CDN edges via the purge API, and confirm index.html is served with Cache-Control: no-cache permanently.

Layer 2: Service Worker. Does the application have a Service Worker? Is the Service Worker update "waiting" (visible in DevTools → Application → Service Workers as "waiting to activate")? A waiting Service Worker means users with open tabs are on the old version. Fix: add skipWaiting() to the new Service Worker's install event for future deployments. For the current incident, send a message to all clients via the Service Worker's clients.matchAll() to prompt them to reload.

Layer 3: Browser HTTP cache. If users are accessing the app without a CDN hit (direct origin request) and their browser has index.html cached with a positive max-age, they serve themselves the stale version. Fix: confirm Cache-Control: no-cache on HTML responses from your origin.

Layer 4: Stale app in memory. The user has the app open in a tab and has not reloaded. The JavaScript is running from the previous load. Fix: implement a version check — on each page visibility change (tab becomes active), check a /api/version endpoint. If the version does not match the app's built-in version constant, show a "A new version is available — reload" banner.

The 20% figure is a clue: it is not 100% (the server is serving the correct version), and it is not 0% (the fix is real). It points to a caching layer that only affects a portion of traffic — likely a specific CDN edge node's cached version, or a specific subset of users with open tabs running a waiting Service Worker.


Scenario 8: "How would you architect the frontend for an admin panel used by 50 internal users on desktop, vs a consumer app used by 5 million users on mobile?"

This question tests whether you understand that architecture is determined by constraints, not by best practices.

The Admin Panel

50 internal users, desktop, no SEO requirement, probably already authenticated. The constraints are minimal:

Rendering: client-side rendering (Vite + React) is entirely acceptable. There is no SEO requirement, users are authenticated before they see anything, and TTFB on an internal tool is less critical than on a consumer app. The team saves significant complexity by not needing Next.js SSR infrastructure.

Bundle size: 50 desktop users on office networks. A 2MB bundle that takes 4 seconds to parse on a mobile device parses in 0.4 seconds on a desktop. Bundle size optimisation is a low priority — ship fast, iterate fast.

Performance targets: internal tools tolerate higher latency than consumer apps. A 3-second initial load is acceptable. A 500ms interaction is fine. Ship features that make the admins more productive; do not spend two weeks optimising LCP that 50 people experience on fast hardware.

Architecture: a standard React SPA with React Router, React Query for server state, and a component library (shadcn/ui or MUI). Team productivity > performance sophistication.

The Consumer Mobile App

5 million users, mobile devices ranging from flagship to low-end, consumer-facing with SEO, potentially 3G connections in emerging markets. The constraints are severe:

Rendering: SSR with Next.js App Router is required. Server-rendered HTML enables immediate LCP, SEO indexing, and fast perceived load even before JavaScript executes. Client-side-only rendering means blank screens for 3-5 seconds on slow connections.

Bundle size: 5 million users on mobile, many on low-end Android with 3G. Every kilobyte of JavaScript is 150KB/s ÷ 8 bits = 18.75KB/s actual throughput. A 400KB gzipped bundle takes 21 seconds to download on genuine 2G. Aggressive code splitting, tree shaking, and third-party script auditing are first-class engineering concerns.

Performance targets: P75 LCP under 2.5 seconds, P75 INP under 200ms, CLS under 0.1. These directly affect Google search ranking and user retention. Measured continuously with field data (FE-08's RUM system).

Architecture: Next.js App Router, Server Components for data-fetching pages, React.lazy() for route-level splitting, next/image for all images, next/font for fonts, third-party scripts loaded with next/script strategy="lazyOnload", full RUM instrumentation.

What the Interviewer Is Listening For

That you do not apply mobile-scale optimisation to an internal tool (over-engineering is as bad as under-engineering). That you clearly derive architecture from constraints rather than applying "best practices" universally.


Scenario 9: "How would you reduce the bundle size of a React app from 1.2MB to under 300KB?"

This is a structured optimisation question. The answer is a process, not a list of techniques.

Step 1: Measure before touching anything. Run ANALYZE=true next build and open the bundle analyser treemap. Record the current breakdown: what are the top 10 modules by size? Which routes are bundled together that should be split? Is anything duplicated across chunks?

Step 2: Identify the highest-ROI targets. Typical findings in a 1.2MB bundle:

  • moment.js: 290KB (uncompressed). Replace with date-fns (tree-shakeable): saves ~250KB.
  • lodash: 70KB. Replace with individual lodash-es imports or native array methods: saves ~60KB.
  • A UI component library imported fully (import * from '@mui/material'): ~600KB. Switch to individual component imports: saves ~400KB.
  • A PDF or charting library used on one route but bundled everywhere: move to a dynamic import on that route: saves the library's full size from the main bundle.

Step 3: Route-level code splitting for all large routes. Next.js does this automatically per page. For routes sharing a layout that imports heavy components, ensure heavy components use React.lazy().

Step 4: Audit third-party scripts. These do not show in the bundle analyser but contribute to JavaScript parse time. Identify all third-party scripts with Coverage tool — any script with >80% unused code is a candidate for removal or lazy loading.

Step 5: Verify tree shaking is working. After replacing libraries, run the bundle analyser again. Confirm the old libraries are gone. Confirm the new libraries' used-only exports appear as small entries.

Step 6: Measure the outcome. Run Lighthouse with CPU throttling. Note the improvement in TTI and LCP. Confirm the bundle size with the analyser. The goal is not 300KB for its own sake — it is the user-visible metric improvement that results from 300KB.

The answer demonstrates you treat bundle reduction as a measurement-driven process, not a list of tricks.


Scenario 10: "Your SPA has a memory leak causing tabs to crash after 30 minutes. Walk me through finding it."

This is FE-07's entire heap snapshot workflow in scenario form. Structure it as the interviewer expects.

Step 1: Reproduce it reliably. Open the app in a fresh tab. Open DevTools → Performance Monitor (⋮ → More tools → Performance monitor). Watch the "JS heap size" graph. Perform the suspected operations repeatedly — navigate between pages, open and close modals, interact with the suspected features. If the heap size grows continuously and does not drop back when you undo the operations, you have confirmed a leak.

Step 2: Baseline snapshot. DevTools → Memory → Heap Snapshot. Take one after a page load and a few warm-up operations to get React internals settled.

Step 3: Perform the leak operation 10 times. Navigate to the suspected page and away 10 times. Open and close the suspected modal 10 times.

Step 4: Second snapshot and comparison. Take a second snapshot. Switch the view to "Comparison." Sort by "# Delta" descending. Look for: component names with unexpected counts (10 instances of DashboardChart when you are no longer on the dashboard page), EventListener objects, Detached HTMLElement objects.

Step 5: Trace the retainer chain. Click the suspicious constructor. In the Retainers panel, read the reference chain from bottom (GC root) to top (leaked object). The chain tells you exactly which reference to remove.

Step 6: Fix and verify. Implement the fix (add the missing removeEventListener, clearInterval, subscription.unsubscribe, or observer.disconnect). Take a third snapshot after forced GC (trash can icon). Confirm the leaked objects no longer appear in the delta.


Scenario 11: "Design a system to handle 10,000 rows in a data grid with sorting, filtering, and live updates"

The constraints determine every decision. 10,000 rows, live updates, filtering and sorting.

Rendering: virtualisation is non-negotiable at 10,000 rows. The DOM cannot hold 10,000 <tr> elements with acceptable performance. Use TanStack Virtual (from FE-02's list rendering section) or TanStack Table which includes virtualisation. The visible window at any time: ~30 rows plus a buffer. 60-100 DOM nodes regardless of dataset size.

Data model: all 10,000 rows live in memory as a JavaScript array. Sorting and filtering happen in-memory — do not make a server round trip for sort/filter changes. At 10,000 rows of typical data (20 columns, mixed types), the dataset is approximately 2-5MB in memory. Acceptable.

Sorting: Array.sort() on 10,000 items is fast (~20ms). Wrap in useMemo keyed on the sort column and direction. The sort only runs when the sort criteria changes, not on every render.

Filtering: filter the array in useMemo keyed on the filter values. Filtering 10,000 items is fast (<5ms). The filtered array is then sorted and passed to the virtualised list.

Live updates via WebSocket: updates arrive as individual row mutations (insert, update, delete). Apply them to the in-memory array. Use useTransition (from FE-02) to wrap the state update — the live update is non-urgent. If the user is actively sorting or filtering, React can defer the live update render until the interaction completes.

Performance concern with live updates: if updates arrive at high frequency (10+ per second), applying each one individually triggers a re-render per update. Batch updates: collect updates for 100ms, apply all at once. One re-render per 100ms regardless of update frequency.

Column resizing and reordering: direct DOM manipulation for the drag interaction (no React re-renders during drag). Commit the new column layout to React state on mouseup/touchend.


Scenario 12: "How do you ensure performance does not degrade as the engineering team scales?"

This is a systems/process question about making performance a durable property of the codebase, not a one-time fix.

The answer has four components:

Automated measurement in CI: Lighthouse CI runs on every pull request. Bundle analyser output is compared to a budget. PRs that exceed the JavaScript budget or LCP budget fail CI and block merging. Performance regressions are caught at review time, not after deployment.

Field data monitoring with alerts: the RUM system from FE-08 runs continuously. Alerts fire when P75 LCP, CLS, or INP exceeds thresholds in a rolling one-hour window. A deployment that regresses Web Vitals is detected within an hour, before it accumulates in Google Search Console data.

Performance review in the PR process: every PR that adds a dependency gets a comment showing its bundle size cost (from bundlephobia.com). Every PR that adds a new route or large component is reviewed for code splitting. This is a culture process, not a tool — but it is what prevents individual engineers from unknowingly adding the 50KB package that tips the budget.

Performance budget ownership: one engineer is responsible for the performance budget each sprint. They review the RUM dashboard, investigate regressions, and ensure the budget is updated when deliberate trade-offs are made (adding a new feature that costs 30KB in bundle size may be worth it — but it should be a deliberate decision, not a surprise).


The Interviewer's Real Evaluation Criteria

Across all scenarios, the interviewer is running four assessments in parallel.

Structured reasoning: did you define the problem before proposing a solution? Did you identify constraints? Did you ask clarifying questions? Engineers who jump to solutions before understanding the problem are red flags at senior level.

Correctness at the physical layer: did your explanation match what the browser actually does? "The browser caches it" is not correct. "The browser stores the response in the HTTP disk cache with a 1-year max-age because of the Cache-Control header we set" is correct. Precision at the physical layer is the signal.

Trade-off awareness: every solution has a cost. Did you articulate the cost? "WebGL is the correct rendering target for 50,000 data points" must be followed by "the trade-off is significantly higher implementation complexity and the requirement for a graphics-competent engineer to maintain it." Solutions presented without costs suggest the engineer does not understand the solution deeply.

Production thinking: did your solution address failure modes, scale, and real user distribution? Solutions that work in demo conditions but fail for 10% of users on slow mobile are not senior-level solutions.


Series Complete

You have read all ten books in The Production Frontend Engineer series.

code
FE-00 ✓  The First Principles Curriculum
FE-01 ✓  Next.js First Principles
FE-02 ✓  React Performance Internals
FE-03 ✓  Bundle & Loading Performance
FE-04 ✓  Web Vitals: From Metrics to Fixes
FE-05 ✓  Network & Caching Strategy
FE-06 ✓  CSS & Rendering Performance
FE-07 ✓  Memory, Leaks & the Browser Heap
FE-08 ✓  Measuring Performance
FE-09 ✓  Senior Frontend Scenarios        ← this book

The engineer who reaches the end of FE-09 and can answer each scenario independently — not by recalling the answer from this book, but by reasoning through it from the physical layer — is the engineer who cannot be replaced by a framework update, a new tool, or an AI code generator. Tools change. Reasoning does not.

The purpose of this series was never to teach you Next.js or React or Web Vitals. It was to build the causal reasoning engine that makes every new technology you encounter understandable on first contact. You now have that engine. Use it.