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
01How do you approach a frontend system design interview question?
senior
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:
- Requirements — functional and non-functional: who the user is, data volume, offline, target devices, key metrics (TTI, update frequency). Write them down explicitly.
- Architecture / API — sketch high-level blocks and the data contract: endpoints, response shape, pagination (cursor vs offset), who owns state.
- Data model — normalized server state separate from client/UI state; what to cache and how to invalidate.
- Interface (components) — component tree, props and events, reusability, loading boundaries.
- 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.
02How do you design a component architecture: composition, container/presentational, compound components?
senior
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.
03How do you architect state in a large SPA: server state, client/UI state, caching, normalization?
senior
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:
- Server state — async, shared, goes stale; it needs caching, request deduplication, background revalidation and invalidation. Don't hand-roll it in Redux.
- Client/UI state — modals, tabs, form, theme; lives in
useState/context/a lightweight store. - Normalization — store entities as
{ byId, allIds }, reference by id instead of nesting duplicates; updating in one place updates everywhere. - Cache invalidation — by keys after mutations; set
staleTimeso 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.
04How do you design a reusable component library / design system?
senior
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 API —
variant/sizeas string unions, not a scatter of booleans; sensible defaults; forward...restandrefto 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.
05How do you design an infinite-scroll feed?
senior
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:
- Cursor pagination —
?cursor=<id>&limit=20; robust to inserts/deletes, unlike offset where items duplicate or get skipped. - Virtualization — render only the visible window (react-virtual/virtuoso), otherwise thousands of DOM nodes kill memory and scrolling.
- Load trigger — IntersectionObserver on a sentinel at the list's end instead of a
scrolllistener. - Cache and optimism — pages cached by cursor; likes applied optimistically with rollback on error.
- 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.
06How do you design an autocomplete / typeahead?
senior
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:
- Debounce — wait ~200–300 ms of typing pause so you don't fire a request per keystroke.
- Cancel races — each new input aborts the previous fetch via
AbortController; so the response for "react" can't overwrite the one for "react native". - Cache — memoize results by query string; a repeat is network-free.
- Accessibility —
role="combobox",aria-activedescendant, arrows/Enter/Esc, announce the result count. - 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.
07How do you implement realtime UI updates: polling vs SSE vs WebSocket?
senior
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.
08How do you make a frontend resilient: error boundaries, retries, degradation, loading/error states?
senior
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:
- Error boundaries — wrap risky subtrees; instead of a white screen, a fallback with a "retry" button.
- Retry with backoff — retry network errors/5xx with exponential delay and jitter; don't retry 4xx.
- UI states — loading (skeletons), empty (a clear empty screen), error (with an action), success — all designed, not forgotten.
- Graceful degradation — a crashed non-critical block doesn't take down the page; core functionality stays.
- 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.