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
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
| 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?”
How 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.
| 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
Data Flow
Component Hierarchy
What 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
State Machine
Data Flow
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
Data Flow
Component Structure
What 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
Interaction Flow
Element State Shape
What 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.
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
Data Flow
Component Hierarchy
What 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.
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
State Machine
Data Flow
Component Structure
What 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 |
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.