Skip to content
Frontend

8 Frontend System Design Interview Questions and Answers

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

8 min read8 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

8 detailed answers

01

How do you approach a frontend system design interview question?

Short answer: Don't rush into drawing UI. Move through a RADIO-style framework: clarify requirements first, then pin down the data/API contract, then the component architecture, state, and finally the cross-cutting concerns (performance, accessibility, error states).

In depth:

  1. Requirements — functional and non-functional: who the user is, data volume, offline, target devices, key metrics (TTI, update frequency). Write them down explicitly.
  2. Architecture / API — sketch high-level blocks and the data contract: endpoints, response shape, pagination (cursor vs offset), who owns state.
  3. Data model — normalized server state separate from client/UI state; what to cache and how to invalidate.
  4. Interface (components) — component tree, props and events, reusability, loading boundaries.
  5. Optimizations — virtualization, code splitting, debounce, canceling racing requests, a11y, loading/empty/error.
Clarify ──► API/Data ──► Components ──► State ──► Cross-cutting
requirements   contract     tree          server/UI   perf · a11y · errors

⚠️ Common mistake: jumping to layout before clarifying requirements and the data contract — the interviewer is listening for exactly this structured top-down movement.

02

How do you design a component architecture: composition, container/presentational, compound components?

Short answer: Prefer composition over inheritance, separate logic from presentation, and for related groups of components use the compound pattern via context — this removes prop drilling and yields a flexible, reusable API.

In depth:

  • Composition over inheritance — assemble UI from small components and children/slots, don't grow class hierarchies.
  • Container vs presentational — the container fetches data and holds state, the presentational takes props and only renders; easier to test and reuse.
  • Compound components<Tabs><Tab/></Tabs> share implicit state through context rather than a dozen props.
  • Against prop drilling — lift state only as far as needed; solve deep passing with context or composition, not pass-through props.
const TabsCtx = createContext<TabsState | null>(null);

function Tabs({ defaultValue, children }: TabsProps) {
  const [value, setValue] = useState(defaultValue);
  return <TabsCtx value={{ value, setValue }}>{children}</TabsCtx>;
}
Tabs.List = TabList;   // compound: shared state via context
Tabs.Trigger = TabTrigger;
Tabs.Panel = TabPanel;

⚠️ Common mistake: turning one component into a "god" with 20 props for every case — better to split into compound parts with a clear contract.

03

How do you architect state in a large SPA: server state, client/UI state, caching, normalization?

Short answer: Split state into server state (backend data) and client/UI state (local interface). Hand server state to a dedicated cache (React Query/SWR/RTK Query), and normalize relational data by id so it never drifts out of sync.

In depth:

  1. Server state — async, shared, goes stale; it needs caching, request deduplication, background revalidation and invalidation. Don't hand-roll it in Redux.
  2. Client/UI state — modals, tabs, form, theme; lives in useState/context/a lightweight store.
  3. Normalization — store entities as { byId, allIds }, reference by id instead of nesting duplicates; updating in one place updates everywhere.
  4. Cache invalidation — by keys after mutations; set staleTime so you don't hit the network needlessly.
Aspect Server state Client/UI state
Source backend the client itself
Freshness stale, revalidates always current
Tool React Query / SWR useState / context
Cache yes, by keys usually none

⚠️ Common mistake: dumping API responses into global Redux and hand-writing loading/error/cache — that reinvents server-state libraries.

04

How do you design a reusable component library / design system?

Short answer: The foundation is tokens (color, typography, spacing) and components with a minimal, predictable API: variants instead of boolean flags, a complete set of states, accessibility by default, and semantic versioning.

In depth:

  • Tokens — a single source of truth for color/spacing/radii as variables; theming and contrast build on them.
  • Component APIvariant/size as string unions, not a scatter of booleans; sensible defaults; forward ...rest and ref to the root element.
  • Accessibility — correct roles/ARIA, focus rings, keyboard navigation built in, not left to the consumer.
  • Variants and states — hover/focus/disabled/loading/error designed up front.
  • Versioning — semver, docs and changelog; breaking API changes are a major bump.
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'ghost';  // not isPrimary/isGhost
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;  // forward the rest

⚠️ Common mistake: sprawl of boolean flags (isPrimary, isLarge, isGhost) instead of one variant — mutually exclusive states become representable and the API turns brittle.

05

How do you design an infinite-scroll feed?

Short answer: Cursor pagination for loading, virtualization to render only visible items, a per-cursor page cache, IntersectionObserver as the load trigger, and optimistic updates for likes/actions. Don't forget scroll restoration.

In depth:

  1. Cursor pagination?cursor=<id>&limit=20; robust to inserts/deletes, unlike offset where items duplicate or get skipped.
  2. Virtualization — render only the visible window (react-virtual/virtuoso), otherwise thousands of DOM nodes kill memory and scrolling.
  3. Load trigger — IntersectionObserver on a sentinel at the list's end instead of a scroll listener.
  4. Cache and optimism — pages cached by cursor; likes applied optimistically with rollback on error.
  5. Scroll restoration — persist an anchor/offset so returning from a detail screen doesn't jump.
[scroll] ─► IntersectionObserver(sentinel)
              │ fetch(?cursor=last_id)

   ┌─────────────────────────┐
   │ cache: page1·page2·page3 │ ──► virtualized window
   └─────────────────────────┘      (renders ~visible rows)

⚠️ Common mistake: offset pagination for a live feed — when new items appear on top, pages shift and the user sees duplicates or gaps.

06

How do you design an autocomplete / typeahead?

Short answer: Debounce input, cache results by query, mandatorily cancel stale requests via AbortController (otherwise a slow response overwrites a fresh one), full keyboard navigation, and an ARIA combobox role.

In depth:

  1. Debounce — wait ~200–300 ms of typing pause so you don't fire a request per keystroke.
  2. Cancel races — each new input aborts the previous fetch via AbortController; so the response for "react" can't overwrite the one for "react native".
  3. Cache — memoize results by query string; a repeat is network-free.
  4. Accessibilityrole="combobox", aria-activedescendant, arrows/Enter/Esc, announce the result count.
  5. States — loading, empty ("no results"), error with a retry affordance.
useEffect(() => {
  const ctrl = new AbortController();
  const t = setTimeout(async () => {
    try {
      const res = await fetch(`/search?q=${query}`, { signal: ctrl.signal });
      setItems(await res.json());
    } catch (e) {
      if (e.name !== 'AbortError') setError(e);  // abort isn't an error
    }
  }, 250);                                       // debounce
  return () => { clearTimeout(t); ctrl.abort(); }; // cancel the stale one
}, [query]);

⚠️ Common mistake: not canceling racing requests — an old response arrives after a newer one and clobbers the current suggestions.

07

How do you implement realtime UI updates: polling vs SSE vs WebSocket?

Short answer: Choose by data direction and frequency. Polling — for infrequent updates and simplicity; SSE — for one-way server push (feed, notifications); WebSocket — for bidirectional low-latency exchange (chat, collaboration). On top of that, optimistic UI and reconciliation when server state arrives.

In depth:

  • Polling — the client asks on an interval; simple, but creates extra requests and lag; long-polling softens it.
  • SSE — a single HTTP stream server→client, auto-reconnect, over plain HTTP; one direction only.
  • WebSocket — a persistent bidirectional connection, minimal latency; costlier infra, needs heartbeat/reconnect.
  • Optimism and reconciliation — apply the change locally at once; on confirmation/conflict, reconcile against server state and roll back on divergence.
Criterion Polling SSE WebSocket
Direction client→server server→client bidirectional
Latency high low very low
Complexity low medium high
When rare updates feed, notifications chat, collaboration

⚠️ Common mistake: reaching for WebSocket where SSE or periodic polling would do — extra infra and reconnect-handling complexity without a real need for bidirectionality.

08

How do you make a frontend resilient: error boundaries, retries, degradation, loading/error states?

Short answer: Contain failures with error boundaries so a widget crashes instead of the whole app; retry transient requests with exponential backoff; always design four states (loading/empty/error/success); and degrade gracefully when non-critical parts fail or the user goes offline.

In depth:

  1. Error boundaries — wrap risky subtrees; instead of a white screen, a fallback with a "retry" button.
  2. Retry with backoff — retry network errors/5xx with exponential delay and jitter; don't retry 4xx.
  3. UI states — loading (skeletons), empty (a clear empty screen), error (with an action), success — all designed, not forgotten.
  4. Graceful degradation — a crashed non-critical block doesn't take down the page; core functionality stays.
  5. Offline — detect navigator.onLine, cache via Service Worker, queue mutations until connectivity returns.
class ErrorBoundary extends React.Component<Props, { error: Error | null }> {
  state = { error: null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  componentDidCatch(error, info) { logToService(error, info); }  // telemetry
  render() {
    return this.state.error
      ? <Fallback onRetry={() => this.setState({ error: null })} />
      : this.props.children;                  // subtree failure isolated
  }
}

⚠️ Common mistake: designing only the happy path and forgetting loading/empty/error — those are the very states a real user hits first.

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