Frontend System Design Basics
How to gather requirements, design frontend systems, and communicate architecture with flowcharts — newsfeed, autocomplete, todo, canvas, tables, forms.
Frontend system design is about thinking through a feature before you code it. It's the same discipline as backend system design — requirements first, then architecture, then implementation — but applied to the client. This guide covers how to gather requirements, structure your thinking, and use flowcharts to communicate your design for common frontend problems.
The Frontend System Design Process
flowchart TD
A[📋 Problem / Prompt] --> B[❓ Gather Requirements]
B --> C[📐 High-Level Architecture]
C --> D[🔀 Data Flow & State]
D --> E[🧩 Component Structure]
E --> F[📝 Spec / Implementation Plan]
B --> B1[Users & context]
B --> B2[Scale & performance]
B --> B3[API & data]
B --> B4[Non-functional]Requirements → Architecture → Data Flow → Components → Spec. Don't skip the first step.
Each step informs the next. If you skip requirements, you'll make architecture choices that don't fit the problem. If you skip the data flow, you'll build components that don't know how to talk to each other. The diagrams are useful for communication, but the real value is in the thinking — understanding why each layer exists and what it's responsible for.
Phase 1: Gathering Requirements
Before drawing any architecture, you need to know what you're building. Use the same discipline as in AI-Assisted Frontend Interview.
Requirements aren't a formality. They're the inputs to your design. "How much data?" directly determines whether you need pagination, infinite scroll, or virtualization. "Who are the users?" determines whether you optimize for mobile touch targets or desktop keyboard shortcuts. Skipping this step means you'll either over-engineer (virtualizing 50 items) or under-engineer (rendering 10,000 DOM nodes).
Requirements Checklist
flowchart LR
subgraph Req["Requirements"]
R1[Users] --> R2[Scale]
R2 --> R3[API]
R3 --> R4[Perf]
R4 --> R5[Security]
R5 --> R6[A11y]
end| Area | Key Questions | Drives |
|---|---|---|
| Users | Who? Use case? Devices? | UX, layout, a11y |
| Scale | How much data? Real-time? | Pagination, virtualization, caching |
| API | Contract? Pagination? Auth? | Data layer, error handling |
| Performance | Budgets? Heavy assets? Offline? | Lazy load, code split, caching |
| Security | User input? Auth? | Sanitization, token handling |
| Accessibility | WCAG level? Keyboard? Screen reader? | Semantic HTML, ARIA, focus |
Why each area matters: Users drive UX decisions — an admin dashboard and a consumer app have different layouts. Scale drives performance strategy — hundreds of items can be paginated; tens of thousands need virtualization. API shape drives your data layer — if the API doesn't support cursor-based pagination, you can't do infinite scroll the same way. Performance, security, and a11y are often afterthoughts; making them explicit in requirements forces you to design for them from the start.
Requirements → Architecture Mapping
Requirements don't just inform what you build — they determine how you build it. The mapping below shows how specific requirements translate into concrete technical choices. This is the bridge between "what does the user need?" and "what library or pattern do I use?"
flowchart TD
subgraph Input["Requirements"]
I1[1000+ items] --> I2[Virtualization]
I3[Real-time] --> I4[WebSocket / Polling]
I5[Offline] --> I6[Service Worker + Cache]
I7[Multi-step form] --> I8[Wizard state machine]
end
subgraph Output["Architecture Choice"]
I2 --> O1[react-window / TanStack Virtual]
I4 --> O2[EventSource / SWR with revalidate]
I6 --> O3[Workbox + IndexedDB]
I8 --> O4[Step state + validation per step]
endHow to read this: "1000+ items" → you need virtualization because the DOM can't handle that many nodes; react-window or TanStack Virtual only render what's visible. "Real-time" → you need a mechanism to refresh; polling with SWR's revalidateInterval or WebSockets for true push. "Offline" → you need a cache layer and a service worker; Workbox + IndexedDB is the standard stack. "Multi-step form" → you need a state machine because the flow has clear steps and validation gates; a simple currentStep number plus validation per step prevents invalid progression.
Phase 2: High-Level Architecture Patterns
Every frontend feature fits into a few patterns. Identifying which one you're building saves you from reinventing the wheel — and from using the wrong wheel. A newsfeed and a todo list need different architectures. Mixing them up leads to overcomplicated state or underpowered data handling.
flowchart TB
A[Feature Type] --> B{Data Source?}
B -->|Server-driven| C[Fetch / Poll / Stream]
B -->|User-driven| D[Local state / Form]
B -->|Hybrid| E[Server + Local cache]
C --> C1[Newsfeed, Table, Dashboard]
D --> D1[Todo, Canvas, Form]
E --> E1[Autocomplete, Search]| Pattern | Examples | Key Concerns |
|---|---|---|
| Server-driven list | Newsfeed, table, dashboard | Pagination, caching, loading states |
| User-driven local | Todo, canvas, form | State management, persistence, validation |
| Hybrid (server + local) | Autocomplete, search | Debounce, cache, optimistic UI |
When to use which: Server-driven features (newsfeed, table, dashboard) are dominated by "fetch data, display data, handle loading/error." Your main concerns are caching, pagination, and keeping the UI responsive during fetches. User-driven features (todo, canvas, form) are dominated by local state — the user creates and mutates data in the browser. Your main concerns are state shape, persistence, and validation. Hybrid features (autocomplete, search) combine both: you fetch from the server based on user input, but you also need local state for the input, debouncing, and often a client-side cache to avoid redundant API calls.
Example 1: Newsfeed
A newsfeed is the canonical server-driven list. The data lives on the server; the client fetches, caches, and displays it. The main design challenges are: (1) how to load more data as the user scrolls, (2) how to handle mutations (like, comment) without blocking the UI, and (3) how to keep the feed performant with potentially hundreds of posts and images.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| Who are the users? | Social consumers | Mobile-first, touch-friendly |
| How much data? | Hundreds of posts, infinite scroll | Virtualization or pagination |
| Real-time? | Optional (new posts) | Polling or WebSocket |
| Interactions? | Like, comment, share | Optimistic UI, local state |
| Images? | Yes, variable size | Lazy load, aspect ratio, placeholder |
Key design decisions: Cursor-based pagination (e.g. ?cursor=xyz&limit=20) is preferred over offset for feeds — it avoids duplicate or missed items when new posts arrive. React Query (or SWR) handles caching and deduplication. For "like" and similar actions, optimistic updates are essential: update the UI immediately, then sync with the server; revert and show a toast if the request fails. Images should be lazy-loaded with loading="lazy" or IntersectionObserver; use aspect-ratio placeholders to avoid layout shift.
Architecture
flowchart TD
subgraph UI["UI Layer"]
A[FeedContainer] --> B[PostCard x N]
B --> C[LikeButton]
B --> D[CommentCount]
end
subgraph Data["Data Layer"]
E[useFeedQuery] --> F[API: GET /feed?cursor=&limit=]
F --> G[Cache: React Query]
G --> H[Infinite scroll: fetchNextPage]
end
subgraph State["State"]
I[Optimistic: like toggle]
J[Local: scroll position]
end
A --> E
B --> I
E --> JData Flow
flowchart LR
A[User scrolls] --> B[IntersectionObserver]
B --> C{More data?}
C -->|Yes| D[fetchNextPage]
D --> E[Append to cache]
E --> F[Re-render list]
C -->|No| F
G[User likes] --> H[Optimistic update]
H --> I[API: POST /like]
I --> J{Success?}
J -->|No| K[Revert + toast]
J -->|Yes| L[Update cache]Component Hierarchy
flowchart TB
FeedPage --> FeedContainer
FeedContainer --> FeedFilters
FeedContainer --> FeedList
FeedList --> PostCard
PostCard --> PostHeader
PostCard --> PostContent
PostCard --> PostImage
PostCard --> PostActions
PostActions --> LikeButton
PostActions --> CommentButtonWhat the layers mean: The UI layer renders PostCards; each card owns its like button and comment count. The data layer is a single useFeedQuery hook that fetches from the API, caches via React Query, and exposes fetchNextPage for infinite scroll. The state layer splits into two: optimistic state for likes (immediate UI update) and scroll position for triggering the next fetch. Keeping these separate prevents scroll logic from coupling with mutation logic.
Pitfalls to avoid: Don't fetch on every scroll event — use IntersectionObserver at the bottom of the list. Don't skip optimistic updates for likes — the UI will feel sluggish. Don't forget to revert on API failure. Don't render all images eagerly — lazy load with a placeholder to keep initial render fast.
Example 2: Autocomplete
Autocomplete is a hybrid: the user types (local state), and you fetch suggestions from the server. The main design challenges are: (1) debouncing to avoid an API call on every keystroke, (2) caching to avoid redundant calls for the same query, (3) handling the loading/empty/selected states cleanly, and (4) keyboard accessibility so users can navigate and select without a mouse.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| Trigger? | On input, after N chars | Debounce 300ms |
| Data source? | API search | Cache results, dedupe |
| Max suggestions? | 10 | Limit API param |
| Keyboard nav? | Yes | Arrow keys, Enter to select |
| Selection behavior? | Fill input, close | Controlled input |
Key design decisions: Debounce at 300ms — short enough to feel responsive, long enough to batch rapid typing. Require at least 2 characters before fetching to avoid noisy or empty results. Cache by query string; React Query does this by default with the query key. The state machine (Idle → Loading → Open/Empty → Selected) prevents invalid states like "dropdown open with no results and loading" — each transition is explicit.
Architecture
flowchart TD
subgraph Input["Input Flow"]
A[User types] --> B[Debounce 300ms]
B --> C{Length >= 2?}
C -->|Yes| D[API: GET /suggest?q=]
C -->|No| E[Clear suggestions]
end
subgraph Cache["Cache Layer"]
D --> F{Cache hit?}
F -->|Yes| G[Return cached]
F -->|No| H[Fetch + cache]
end
subgraph UI["UI Flow"]
G --> I[Render dropdown]
H --> I
I --> J[Keyboard: arrow/enter]
J --> K[Select + close]
endState Machine
stateDiagram-v2
[*] --> Idle
Idle --> Loading: input.length >= 2
Loading --> Open: results received
Loading --> Empty: no results
Open --> Idle: blur / escape
Open --> Selected: enter / click
Selected --> Idle: reset
Empty --> Idle: blur / escapeData Flow
flowchart LR
A[Input] --> B[debouncedSearch]
B --> C[queryClient.fetchQuery]
C --> D{Cache?}
D -->|Hit| E[Suggestions]
D -->|Miss| F[API]
F --> G[Cache result]
G --> E
E --> H[Dropdown]
I[Select] --> J[onChange]
J --> K[Close + fill]What the flow means: User types → debounced function fires after 300ms of no input → if query length ≥ 2, hit the API (or cache). Cache hit returns immediately; cache miss fetches and stores. The dropdown renders when you have results; keyboard arrow/enter or click selects and fills the input. The state machine ensures you never show "loading" and "empty" at once, and that blur or escape always returns to Idle.
Pitfalls to avoid: Don't forget to cancel in-flight requests when the user types again — otherwise "cat" might return after "category" and overwrite. Don't skip keyboard nav — many users rely on it. Don't fetch on every keystroke — you'll hammer the API and waste bandwidth.
Example 3: Todo List
A todo list is user-driven: the data is created and mutated locally. The main design challenges are: (1) keeping state simple (a single array of todos plus a filter), (2) persisting to localStorage or an API without blocking the UI, (3) deriving the filtered list without duplicating state, and (4) handling the empty state and clear-completed flow.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| Persistence? | localStorage or API | Sync layer |
| Filters? | All / Active / Completed | Derived state |
| Single or multiple lists? | Single | Simpler state |
| Due dates? | No | No date picker |
Key design decisions: Store todos as an array; the filter (All/Active/Completed) is separate state. The filtered list is derived: todos.filter(t => filter === 'All' || ...) — never store the filtered list separately. Persistence goes in a useEffect that runs when todos changes: sync to localStorage or call a mutation. For API persistence, use optimistic updates — update local state first, then sync; revert on failure.
Architecture
flowchart TD
subgraph State["State"]
A[todos: Todo[]] --> B[filter: FilterType]
A --> C[addTodo, toggleTodo, deleteTodo]
end
subgraph UI["UI"]
D[TodoApp] --> E[TodoInput]
D --> F[TodoFilters]
D --> G[TodoList]
G --> H[TodoItem]
end
subgraph Persist["Persistence"]
A --> I{API?}
I -->|Yes| J[useMutation + sync]
I -->|No| K[useEffect + localStorage]
end
E --> C
F --> B
H --> CData Flow
flowchart LR
A[User adds] --> B[addTodo]
B --> C[Update state]
C --> D[Persist]
D --> E[localStorage / API]
F[User toggles] --> G[toggleTodo]
G --> H[Update by id]
H --> D
I[User filters] --> J[setFilter]
J --> K[Derive filtered list]
K --> L[Re-render]Component Structure
flowchart TB
TodoApp --> TodoHeader
TodoApp --> TodoInput
TodoApp --> TodoList
TodoApp --> TodoFooter
TodoList --> TodoItem
TodoItem --> TodoCheckbox
TodoItem --> TodoLabel
TodoItem --> TodoDeleteButton
TodoFooter --> TodoCount
TodoFooter --> TodoFilters
TodoFooter --> ClearCompletedWhat the layers mean: State is minimal: todos array and filter enum. The UI is a straightforward hierarchy — input at top, list in middle, footer with count and filters. Persistence is a side effect: when todos changes, write to localStorage or trigger an API mutation. The key insight is that the filtered list is derived — you never have filteredTodos as state, only as a computed value. That keeps the source of truth in one place.
Pitfalls to avoid: Don't store filtered todos as state — you'll get out of sync when todos change. Don't block the UI on persistence — do it in the background. Don't forget to sanitize user input if you're rendering it (XSS). For API sync, handle conflicts — what if the user edits on two devices?
Example 4: Canvas Page (Canva-like)
A canvas app is user-driven with complex local state. The main design challenges are: (1) representing elements as a unified data structure (shapes, text, images with different props), (2) handling drag, resize, and transform without jank, (3) separating canvas-level transform (zoom, pan) from element-level transform (position, size), and (4) making it all work with keyboard for accessibility.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| Elements? | Shapes, text, images | Element type union |
| Interactions? | Drag, resize, rotate, delete | Transform state per element |
| Zoom/pan? | Yes, canvas-level | Canvas transform matrix |
| Undo/redo? | Optional | Command pattern / history stack |
| Persistence? | Optional | Serialize elements to JSON |
Key design decisions: Elements are a discriminated union: { type: 'shape', ... } | { type: 'text', ... } | { type: 'image', ... }. Each has id, x, y, width, height, and type-specific props. Canvas zoom/pan is a separate transform applied to the container — use CSS transform for GPU acceleration. For drag, track selectedId and update the element's x,y on mousemove; use requestAnimationFrame or throttle to avoid layout thrashing. For resize, same idea but update width, height.
Architecture
flowchart TD
subgraph Canvas["Canvas Layer"]
A[CanvasContainer] --> B[Zoom/Pan controls]
A --> C[Element layer]
C --> D[ShapeElement]
C --> E[TextElement]
C --> F[ImageElement]
end
subgraph State["State"]
G[elements: Element[]] --> H[selectedId: string | null]
G --> I[canvasTransform: { zoom, pan }]
G --> J[addElement, updateElement, deleteElement]
end
subgraph Sidebar["Sidebar"]
K[ElementPalette] --> L[Drag to add]
end
L --> J
D --> J
E --> J
F --> JInteraction Flow
flowchart TD
A[User drags from palette] --> B[onDrop on canvas]
B --> C[createElement at drop position]
C --> D[Add to elements array]
E[User drags element] --> F[mousedown: set selectedId]
F --> G[mousemove: update element x,y]
G --> H[mouseup: commit]
I[User zooms] --> J[wheel: update canvasTransform.zoom]
J --> K[Apply CSS transform]
L[User pans] --> M[drag background: update pan]
M --> KElement State Shape
flowchart LR
subgraph Element["Element"]
A[id] --> B[type: shape|text|image]
B --> C[props: type-specific]
C --> D[x, y, width, height]
D --> E[rotation?]
endWhat the layers mean: The canvas has two transform levels: (1) canvas zoom/pan applied to the whole container, (2) each element's own position and size. The palette is a separate component that emits "add element" on drop. State is elements: Element[] and selectedId; mutations are addElement, updateElement, deleteElement. The element shape is a union so you can add new types (e.g. video) without breaking existing code.
Pitfalls to avoid: Don't put zoom/pan on each element — it belongs on the canvas. Don't update state on every mousemove without throttling — you'll flood React. Use transform for positioning, not left/top, for better performance. For undo/redo, push snapshots to a history stack; don't try to diff — it's error-prone.
Example 5: Table with Pagination
A data table with pagination is server-driven: the client sends page, limit, sort, and filter params, and the server returns a slice of data. The main design challenges are: (1) keeping URL or state in sync with the table params so users can share or bookmark, (2) refetching when params change without manual orchestration, (3) handling loading and empty states, and (4) making sortable headers and filters accessible.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| How many rows? | Hundreds to thousands | Pagination, not virtualization |
| Sortable? | Yes, server-side | API params: sort, order |
| Filterable? | Yes, by column | API params: filter |
| Row actions? | View, edit, delete | Action menu |
| Page size? | 10, 25, 50 | URL or state |
Key design decisions: All table params (page, limit, sort, order, filters) live in one place — either React state or URL search params. URL is better for shareability: ?page=2&sort=date&order=desc. React Query's queryKey includes these params, so changing them automatically triggers a refetch. The API contract should return { items: [], total: number } so you can compute total pages. Sort and filter are server-side — the client sends params, the server does the work.
Architecture
flowchart TD
subgraph API["API Contract"]
A[GET /items?page=&limit=&sort=&order=&filter=] --> B[Response: items[], total]
end
subgraph State["Client State"]
C[page, limit, sort, order, filters] --> D[Derive query params]
D --> E[useQuery with params]
E --> F[Table data]
end
subgraph UI["UI"]
G[DataTable] --> H[TableHeader - sortable]
G --> I[TableBody - rows]
G --> J[Pagination - page nav]
G --> K[Filters - dropdowns]
end
F --> G
H --> C
J --> C
K --> CData Flow
flowchart LR
A[User changes page] --> B[setPage]
B --> C[params change]
C --> D[useQuery refetch]
D --> E[New data]
F[User sorts] --> G[setSort, setOrder]
G --> C
H[User filters] --> I[setFilters]
I --> C
J[URL sync?] --> K[useSearchParams]
K --> CComponent Hierarchy
flowchart TB
TablePage --> TableToolbar
TablePage --> DataTable
TablePage --> TablePagination
TableToolbar --> SearchInput
TableToolbar --> FilterDropdowns
DataTable --> TableHeader
DataTable --> TableBody
TableHeader --> SortableHeader
TableBody --> TableRow
TableRow --> TableCell
TableRow --> RowActions
TablePagination --> PageSizeSelect
TablePagination --> PageNavWhat the flow means: Params (page, limit, sort, order, filters) are the single source of truth. When the user changes page, sort, or filter, you update params; React Query refetches because the query key changed. If you sync to URL, useSearchParams reads and writes — back/forward and bookmarking work. The table just renders what it receives; it doesn't manage pagination logic.
Pitfalls to avoid: Don't fetch on mount and then filter client-side for large datasets — the server should do the work. Don't forget to disable the "Next" button when you're on the last page. Don't lose filter state when changing pages — params should accumulate. For sortable headers, indicate sort direction (arrow icon) and make them keyboard-activatable.
Example 6: Registration Form
A multi-step registration form is user-driven with validation gates. The main design challenges are: (1) managing step progression so users can't skip ahead without valid data, (2) validating each step before allowing "Next", (3) handling back navigation without losing data, (4) submitting only when all steps are valid, and (5) accessible focus management when transitioning between steps.
Requirements Gathering
| Question | Answer (assumed) | Design Impact |
|---|---|---|
| Single or multi-step? | Multi-step (email → password → profile) | Wizard state |
| Validation? | Per step, before next | Zod/Yup schema per step |
| API? | POST per step or single submit | Affects flow |
| OAuth? | Optional, step 0 | Social buttons |
Key design decisions: Use a state machine: currentStep (1, 2, or 3) plus formData (accumulated across steps). Validate on "Next" — if invalid, show errors and stay; if valid, advance. "Back" never validates — you're allowed to go back and edit. On final submit, validate the full object (Zod/Yup schema) before sending. Focus management: when moving to the next step, focus the first input of that step so keyboard users aren't lost.
Architecture
flowchart TD
subgraph Steps["Step Flow"]
A[Step 1: Email] --> B[Validate]
B --> C[Step 2: Password]
C --> D[Validate]
D --> E[Step 3: Profile]
E --> F[Submit]
end
subgraph State["State"]
G[formData: Partial<User>] --> H[currentStep: 1|2|3]
G --> I[errors: Record<field, string>]
end
subgraph Validation["Validation"]
B --> J[email format, uniqueness?]
D --> K[password strength, match]
F --> L[Final schema validation]
endState Machine
stateDiagram-v2
[*] --> Step1
Step1 --> Step2: email valid
Step2 --> Step3: password valid
Step3 --> Submitting: profile valid
Submitting --> Success: 2xx
Submitting --> Error: 4xx/5xx
Error --> Step3: retry
Step1 --> Step1: back
Step2 --> Step1: back
Step3 --> Step2: backData Flow
flowchart LR
A[User fills step 1] --> B[onChange]
B --> C[Update formData]
C --> D[Validate on blur / next]
D --> E{Valid?}
E -->|No| F[Set errors, stay]
E -->|Yes| G[Clear errors, next step]
H[User submits step 3] --> I[Validate all]
I --> J[POST /register]
J --> K{Success?}
K -->|Yes| L[Redirect / success]
K -->|No| M[Show error, stay]Component Structure
flowchart TB
RegistrationPage --> ProgressIndicator
RegistrationPage --> FormWizard
FormWizard --> Step1Form
FormWizard --> Step2Form
FormWizard --> Step3Form
Step1Form --> EmailInput
Step1Form --> NextButton
Step2Form --> PasswordInput
Step2Form --> ConfirmPasswordInput
Step2Form --> BackButton
Step2Form --> NextButton
Step3Form --> NameInput
Step3Form --> OptionalFields
Step3Form --> BackButton
Step3Form --> SubmitButtonWhat the flow means: Each step has its own form fields and validation. formData accumulates — step 1 adds email, step 2 adds password, step 3 adds name. Validation runs when the user clicks "Next" or "Submit"; errors attach to fields and prevent progression. The state machine ensures you can't get to Step 3 without valid email and password. On submit, you POST the full formData; the server may validate again (never trust the client alone).
Pitfalls to avoid: Don't validate on every keystroke — validate on blur or on "Next". Don't lose data when going back — formData persists. Don't forget to handle API errors (duplicate email, network failure) — show them inline or as a toast. For a11y, use aria-live for error announcements and ensure the focus trap moves correctly between steps.
Summary: Design Before You Code
| Step | Action |
|---|---|
| 1. Gather | Ask about users, scale, API, performance, security, a11y |
| 2. Classify | Server-driven, user-driven, or hybrid? |
| 3. Architect | Data flow, state shape, component hierarchy |
| 4. Diagram | Flowcharts for data flow, state machines for flows |
| 5. Spec | Write requirements and acceptance criteria before implementation |
flowchart LR
A[Requirements] --> B[Architecture]
B --> C[Data Flow]
C --> D[Components]
D --> E[Spec]
E --> F[Code]The flowcharts in this guide are starting points. Adapt them to your specific requirements. The discipline of drawing before coding is what matters — not the exact diagram. Use the explanations to understand why each piece exists; the diagrams show what the pieces are and how they connect.