Free lesson · Frontend System Design
Let's actually do one
You've got the three tools now: you know what the round tests, you have RADIO with the gaps overlay, and you have a time budget. Frameworks are useless until you watch them run on a real problem, so we're going to design autocomplete search - the typeahead that drops a list of suggestions under a search box as you type. It is the single most-asked frontend design question, which makes it the perfect first walkthrough, and we'll see it again at full machine-coding depth in Module 10. Here, the goal is different: I want you to feel the shape of a complete answer, narrated the way I'd narrate it in the room, clock running.
I'll mark the time as we go, so you can feel the budget breathe. Imagine the interviewer just said: "Design an autocomplete search component, like the search bar on Google or Amazon."
Your instinct is to start coding the input. Don't. Watch where we start instead.
R - Requirements (0:00 → 7:00)
I don't draw anything yet. I ask, because the word "autocomplete" hides at least five different products.
"A few quick questions to scope this. First - what are we searching over? A small fixed list on the client, or a large dataset that lives on a server?" The answer changes everything: a client-side list is a filtering problem, a server-side dataset is a network problem. Say the interviewer says server-side, large dataset. Good - now network resilience is in play.
"Second - what's the user's context? Mobile-first, desktop, both? Global audience or one region?" Say both, global, including users on slow networks. That single answer just made debouncing, request cancellation, and caching non-negotiable rather than nice-to-have.
"Third - does selecting a suggestion navigate somewhere, or just fill the input?" Say it triggers a search/navigation. "And do we need to support keyboard navigation and screen readers?" The right answer is always yes, and a good interviewer says yes - accessibility is frequently the thing they're most quietly watching.
Now I write the scope in the corner of the board and say it back:
Functional: suggestions appear as the user types · keyboard navigable (arrows, enter, escape) · selecting one triggers search · clearing the input clears suggestions. Non-functional: feels instant (perceived latency under ~100ms) · resilient on slow/variable networks · accessible (ARIA combobox, screen-reader announcements) · efficient (don't hammer the server on every keystroke).
That read-back took me ninety seconds and it just immunized me against being ambushed at minute 25. We're at 7:00. Move.
Tip: Notice that not one of my questions was "how many users?" Every question targeted a constraint that changes the design: data location, network conditions, interaction model, accessibility. That's what 7 minutes of requirements should buy - the constraints, not the trivia.
A - Architecture (7:00 → 16:00)
Now I draw, and I draw responsibilities, not React. The key architectural decision I want to telegraph immediately: the logic does not live in the view. I separate the thing that renders from the thing that decides.
graph TD
User([User types]) --> Input[SearchInput<br/>view: renders the box]
Input -->|query change| Controller[useAutocomplete hook<br/>owns ALL logic:<br/>debounce, cancel, cache, state]
Controller -->|needs data| Network[Network Layer<br/>fetch + cancellation]
Network -->|HTTP| Server[(Suggestions API)]
Controller -->|read/write| Cache[Client Cache<br/>LRU by query string]
Controller -->|state| List[SuggestionsList<br/>view: renders results,<br/>highlights active item]
List -->|select| Controller
Controller -->|on select| Action[Trigger search /<br/>navigation]
This is a component architecture diagram for autocomplete. The user types into a SearchInput, which is a pure view component that only renders the box. Query changes flow into a controller hook called useAutocomplete, which owns all the logic: debouncing, request cancellation, caching, and state. The controller talks to a network layer responsible for fetching and cancellation, which makes HTTP calls to a suggestions API. The controller also reads from and writes to a client cache, an LRU keyed by query string. The controller passes state down to a SuggestionsList view that renders results and highlights the active item. Selecting an item flows back up to the controller, which triggers the search or navigation. The crucial point is that the two view components are dumb, and all logic is concentrated in the controller hook.
I narrate the why: "I'm putting every piece of logic - debouncing, cancellation, caching, state - into a single controller hook, and keeping the input and the list as dumb view components. This matters because it makes the logic testable in isolation and lets me reuse the behavior with a totally different UI. The network layer is separate too, so cancellation lives in one place."
That separation is the senior signal in this phase. A junior tangles the fetch call inside the input's onChange. We're at 16:00. Move.
Interactive animation: The logic does not live in the view - useAutocomplete owns debounce, cancellation, cache and state behind two dumb views.
D - Data Model (16:00 → 22:00)
Client state for autocomplete is small but every field earns its place. I sketch the shape:
type AutocompleteState = {
query: string; // what's currently in the input
suggestions: Suggestion[];// the current list to render
activeIndex: number; // which item is keyboard-highlighted (-1 = none)
status: 'idle' | 'loading' | 'success' | 'error';
// cache is separate, not per-render state:
// Map<query, Suggestion[]> -- an LRU outside the render cycle
};
type Suggestion = {
id: string;
text: string; // display text
// optionally: type, icon, url for navigation
};
I call out the deliberate choices. "activeIndex is separate from the suggestions list - it's UI state, not server state, and it changes on every arrow keypress without refetching. status is a state machine, not two booleans like isLoading and isError, because those booleans let you represent impossible states like loading-and-error-at-once. And the cache is not React state - it lives outside the render cycle in an LRU map, because I don't want a cache write to trigger a re-render."
That distinction - server state vs UI state vs cache, each living where it belongs - is exactly the Module 3 material, previewed here. We're at 22:00, dead on budget. Move.
Important: Modeling
statusas a single enum (idle | loading | success | error) instead of separate booleans is a small choice that signals real experience. Boolean soup (isLoading,isError,isSuccess) makes illegal states representable and is the source of countless "spinner stuck forever" bugs. A state machine makes the illegal states impossible.
I - Interface (22:00 → 29:00)
Two interfaces: the network contract and the component contract.
Network contract. The request is just the query; the response is a ranked list. I keep the payload tiny because global-slow-network is a stated requirement:
// GET /api/suggestions?q=lap&limit=8
{
"query": "lap",
"suggestions": [
{ "id": "1", "text": "laptop" },
{ "id": "2", "text": "laptop stand" },
{ "id": "3", "text": "laptop bag" }
]
}
I echo the query back in the response on purpose - that's how the client verifies a late response still matches the current input, which is the fix for the out-of-order problem I'm about to hit in Optimizations.
Component contract. The reusable hook's API:
const {
query, setQuery, // controlled input value
suggestions, status, // what to render
activeIndex, // keyboard highlight
onKeyDown, // arrow/enter/escape handler
onSelect, // commit a suggestion
} = useAutocomplete({
fetcher, // injected: how to get suggestions (testable!)
debounceMs: 200,
cacheSize: 50,
});
"I inject the fetcher rather than hardcoding the endpoint - that's what makes this hook unit-testable without a network, and reusable for searching products, users, or anything else." We're at 29:00, right on the second checkpoint. Now the phase that wins the round.
O - Optimizations (29:00 → 42:00)
I announce my plan explicitly, because naming the gaps is the move: "I'll go deep on two areas that matter most for these requirements - request management and accessibility - and I'll name a few more I'd handle in production but won't detail unless you want me to."
Deep area 1: request management
Interactive animation: Debounce 200ms, abort the stale request, then drop the late 'lap' response because the current query is 'lapt'.
This is where "feels instant on a slow network" gets earned, and it's three distinct problems most people collapse into one.
Debouncing. If I fire a request on every keystroke, typing "laptop" sends six requests, five of them wasted. I wait until the user pauses - roughly 200ms - before firing. "200ms is a deliberate number: short enough to feel responsive, long enough that mid-word it stays quiet. I'd tune it with real data."
Cancellation. Even debounced, requests overlap on a slow network. If "lap" is still in flight when "lapt" fires, I cancel the first with an AbortController. No point spending the user's bandwidth on a response I'll discard.
Out-of-order responses - the subtle one. Even with cancellation, responses can race. Suppose the response for "lap" is slow and arrives after the response for "lapt." Without protection, the stale "lap" results overwrite the fresh "lapt" results, and the user sees suggestions for a query they've already moved past. This is the exact freeze-the-candidate question from the time-management lesson, and here's the fix: I echoed the query back in the response, so I discard any response whose query doesn't match the current input. The late "lap" response arrives, I check - current query is "lapt", response query is "lap", mismatch - drop it.
sequenceDiagram
participant U as User
participant H as useAutocomplete
participant N as Network
participant S as Server
U->>H: types "lap"
H->>H: debounce 200ms
H->>N: fetch("lap") [req A]
U->>H: types "lapt" (before A returns)
H->>N: abort req A
H->>N: fetch("lapt") [req B]
S-->>H: response A ("lap") arrives late
H->>H: query mismatch ("lap" != "lapt") → DISCARD
S-->>H: response B ("lapt")
H->>H: query matches → render
This sequence diagram traces the request-management logic across the user, the autocomplete hook, the network layer, and the server. The user types "lap." The hook debounces for two hundred milliseconds, then fires request A for "lap." Before A returns, the user types "lapt." The hook aborts request A and fires request B for "lapt." The late response for A, carrying the query "lap," arrives at the hook, which checks it against the current query "lapt," finds a mismatch, and discards it. Then response B for "lapt" arrives, matches the current query, and is rendered. This shows debouncing, cancellation, and out-of-order protection working together.
Interview: If you can articulate the out-of-order-response problem unprompted and propose the query-echo fix, you have likely just separated yourself from 80% of candidates. It's the detail that only shows up when you've actually shipped a typeahead and watched it flicker stale results in production. Naming it proves depth more than any amount of API recall.
Deep area 2: accessibility
Autocomplete is a known accessibility minefield because it's a custom widget pretending to be a native one. The standard is the ARIA combobox pattern, and getting it right is a strong, often decisive signal.
- The input gets
role="combobox",aria-expanded(true when the list is open),aria-controlspointing to the list, andaria-activedescendantpointing to the highlighted item's ID. - The list gets
role="listbox"; each item getsrole="option"and a unique ID. - Keyboard: Down/Up move
activeIndexand updatearia-activedescendantwithout moving DOM focus - focus stays in the input so the user can keep typing. Enter selects the active item. Escape closes the list. Home/End jump to first/last. - Screen-reader announcements: an
aria-liveregion announces the result count - "8 suggestions available" - so a non-sighted user knows the list populated.
"The non-obvious part is that focus never leaves the input. Beginners move focus into the list on arrow-down, which breaks typing. The aria-activedescendant pattern lets you have a 'virtual' highlight in the list while real focus stays in the box."
The state machine tying it together
Before I name the gaps, I show how the states connect, because a reviewer can see the whole behavior at a glance:
stateDiagram-v2
[*] --> Idle
Idle --> Loading: user types (after debounce)
Loading --> Success: response matches current query
Loading --> Error: request fails
Loading --> Loading: query changes (cancel + refetch)
Success --> Loading: user types again
Success --> Idle: input cleared
Error --> Loading: user types again / retry
Success --> [*]: suggestion selected → trigger search
This state diagram shows the autocomplete lifecycle. It starts Idle. When the user types, after debouncing, it moves to Loading. From Loading it can reach Success if a response matches the current query, Error if the request fails, or loop back to Loading if the query changes, which cancels and refetches. From Success, typing again returns to Loading, clearing the input returns to Idle, and selecting a suggestion exits the machine to trigger the search. From Error, typing again or retrying returns to Loading. This captures every legal transition and makes illegal states, like loading-and-error simultaneously, impossible.
Naming the gaps (the deliberate scope close)
With about three minutes left, I close cleanly: "I'm going to name a few optimizations I'd handle in production but won't detail now, so you know they're on my radar:"
- Caching: the LRU by query string means re-typing "lap" after backspacing from "lapt" is instant, no network. I'd also respect HTTP cache headers for the static assets.
- Prefetch on highlight: when a suggestion is keyboard-highlighted, I could prefetch its destination so selection feels instant.
- Result virtualization: if the list could be long, render only visible rows. For ~8 suggestions it's unnecessary - I'd be gold-plating to add it here.
- Minimum query length & empty-state UX: don't fire on a single character; show recent searches when the box is empty.
- Analytics & ranking feedback: log which suggestions get selected to improve ranking - a product concern more than a frontend one.
"Of those, caching and prefetch give the biggest perceived-performance win; the rest are situational." We're at 42:00. Three-minute buffer for their questions.
Pros:
- Logic fully isolated in a testable, reusable hook
- Handles the three real network failure modes: waste, overlap, and races
- Accessible by the ARIA combobox standard, not bolted on
- State machine prevents illegal UI states
- Tiny payload + caching respect the slow-network requirement
Cons:
- Debounce adds intentional latency (the 200ms is a tuned compromise)
- Client cache can serve stale results if the dataset changes fast
- aria-activedescendant pattern is more complex than moving real focus
- Injected fetcher adds a layer of indirection for simple use cases
Why this is the template for every question that follows
Interactive animation: Scope it, separate logic from view, model the state, define the contract, go deep, and name the gaps.
Step back and notice what just happened. I never once said "I'm good at React." I asked four scoping questions, separated logic from view, modeled state as a machine, defined a tiny contract, went deep on two things that mattered for the stated constraints, and named the rest as deliberate scope. That sequence works for autocomplete, for an image carousel, for a news feed, for Google Docs. The problem changes; the shape doesn't.
And the depth lived exactly where the requirements pointed. Because the interviewer said "global, slow networks," request management had to be a deep area - and because they said "accessible," the combobox pattern had to be the other. If they'd said "internal admin tool, desktop only, fast network," I'd have spent that same depth budget on component API design and configurability instead. The requirements you gathered in the first seven minutes tell you where to spend the depth in the last thirteen. That's the whole game, and it's why we don't skip Requirements.
Interview: Close every round the way I closed this one: a 20-second summary of the design, the two things you went deep on, the gaps you named, and one sentence on "what I'd do next with more time." Interviewers remember the last thing they hear. A clean, prioritized close leaves them with the impression of someone organized and senior - which is the impression that turns into a "hire."
What you should carry out of Module 0
You came in thinking this round might be backend design with React on top, or a coding test in disguise. It's neither. It's a judgment test - breadth, depth, communication, tradeoffs - run on problems where the network is hostile and the device is unknown. You now have the full kit to demonstrate that judgment on demand: a clear picture of what's tested, the RADIO framework with the gaps-naming overlay, a time budget with recovery scripts, and a complete worked example showing all of it executing on the clock.
Everything after this module is depth. We'll spend whole lessons on the things this walkthrough touched in a sentence - how the browser actually turns your response into pixels, the real tradeoffs between REST and GraphQL and gRPC, the full spectrum of client storage, the entire frontend threat model, and the machine-coding builds where autocomplete returns at production depth. But you'll carry this shape into every one of them. When in doubt, in any room, on any prompt: scope it, separate logic from view, model the state, define the contract, go deep where the requirements point, and name what you're leaving out. That's not a trick for interviews. That's just how the work is done.