Skip to content

    Frontend System Design

    A Repeatable Framework for Front-End Design Problems

    Learn a seven-pass framework for front-end design problems and apply it end to end to a search-as-you-type box and a filtered product list.

    By Published 18 min read

    • frontend-system-design
    • design-process
    • framework
    • typeahead
    Share

    Flagship lessonbeginnerFrontend System Design

    Before this: Frontend Requirements: Users, Devices and Networks

    What you'll learn: By the end of this lesson you will be able to turn a vague front-end request ("add instant search", "add filters to the list") into a design by working through seven passes in a fixed order: scope, measure, data, interface, delivery, failure and decision. You will produce one small artifact per pass, apply the sequence to two worked problems, and recognise the bug that appears when a pass is skipped.

    Pilots run a checklist before every flight, not because they forget how to fly but because a fixed order stops a skipped check becoming a surprise at altitude. A design problem has the same shape. Experienced people still jump to the interesting part (which library, which animation) and find out three weeks later that nobody asked what happens when the request fails.

    A framework here means a fixed order of questions, each with one small thing you write down. It does not decide for you. It makes sure decisions are made on purpose, early, while they are cheap to change.

    The running example is the online store used across this course. Problem A is a search-as-you-type box: as the shopper types, suggestions appear under the input. It is small enough to show every pass. Problem B is a filtered product list: a category page with filters, sorting and a way to see more products. It is where the passes start to interact.

    The core idea

    A front-end design problem arrives as one sentence from someone who does not care how it works. The framework turns it into seven answers, ordered from cheap-to-change questions to expensive-to-change decisions.

    1. Each pass has one question and one artifact. An artifact is something you can read, check and show a colleague: a list of journeys, testable sentences, a state diagram, a decision record. If a pass produced nothing you can point at, it did not happen.
    2. The order follows cost of change. Scope and targets cost nothing to change on paper. A rendering approach or a URL structure costs a lot after launch, so settle the first before the second.
    3. The passes loop. A number from pass 2 can shrink the scope from pass 1, and a failure found in pass 6 can add a state to pass 4. Say so when it happens and go back.

    This is a teaching framework, not a standard, and the names are this course's own. The back-end version, with capacity estimates, an API and a data model, is in System Design: A Repeatable Approach. The front end keeps the habit but swaps the middle: instead of estimating servers you describe a device, a network and the states of a screen.

    Figure 1. The seven passes and the two loops that happen most often

    flowchart LR accTitle: The seven passes of the front-end design framework accDescr: Scope, measure, data, interface, delivery, failure and decision run in order. Dotted arrows return from measure to scope, and from failure to interface, when a pass changes an earlier answer. S1["1 Scope"] --> S2["2 Measure"] S2 --> S3["3 Data"] S3 --> S4["4 Interface"] S4 --> S5["5 Delivery"] S5 --> S6["6 Failure"] S6 --> S7["7 Decision"] S2 -.->|"number changes
    the scope"| S1 S6 -.->|"missing state
    or fallback"| S4

    How to read this: the solid arrows are the order of work. The dotted arrows are loops: a measurable target that forces you to cut scope, and a failure case that forces a new interface state.

    The seven passes

    Pass The question The artifact you write down Covered in depth
    1. Scope Who uses this, for what, and what is out? Journeys, an out-of-scope list, assumptions Requirements
    2. Measure What does good mean, on which device and network? Testable sentences and a small budget Requirements, Core Web Vitals
    3. Data What data, who owns it, how fresh, where does state live? A data and state table Planned state and data lessons
    4. Interface What are the states, and how is it operated? A state diagram and an interaction list, including keyboard and screen reader Event loop for responsiveness
    5. Delivery Where does the HTML come from, what code ships, what is cached? A delivery sketch Rendering strategies, HTTP caching
    6. Failure What breaks, and what does the person see? A failure list with a response for each Big picture
    7. Decision What did we choose, what did it cost, when do we revisit? A decision record Trade-off analysis

    How to run each one:

    • Scope. Write journeys as a person's goal ("find a pair of shoes and open its page"), not as components. Write what is out of scope, because that list stops the problem growing. Label guesses as assumptions.
    • Measure. Use the sentence template from the requirements lesson: a journey, a condition, an outcome and a way to check. At the time of writing, web.dev says a good Interaction to Next Paint (INP, a responsiveness metric) is 200 milliseconds or less, and that a good threshold to measure is the 75th percentile of page loads, segmented across mobile and desktop. In web.dev's words, a performance budget is a set of limits imposed on metrics that affect site performance.
    • Data. For each piece of data on screen, write who owns it (the server, the URL, a component, a store), how stale it may be, and how it changes. This stops two parts of the page keeping two copies of one fact.
    • Interface. List the states of the screen (loading, empty, error, partial, success) and every way to operate it: pointer, touch, keyboard, screen reader. Reuse existing WAI-ARIA patterns.
    • Delivery. Decide where the first HTML comes from and what code the first visit downloads. web.dev's rendering guide says generating pages on the server takes time, which can increase time to first byte (TTFB), while static rendering achieves a consistently fast TTFB but must generate an HTML file for every possible URL.
    • Failure. For every network call ask: what if it is slow, wrong, late or never comes back? This pass is the one most often skipped, and Problem A shows the cost.
    • Decision. Write the choice, what it buys, what it costs and the trigger to look again. The next lesson, Trade-Off Analysis for Front-End Decisions, covers how to compare the options that feed this record.

    Problem A: search-as-you-type (the minimal example)

    The request. "Add instant search to the store." One sentence, no detail.

    Pass 1, scope. Journeys: type a few letters, see up to eight suggestions, pick one to open its product page, or press Enter for a full results page. Out of scope for now: spelling correction (a server concern), history, voice input. Assumption (illustrative): most traffic is on phones, and another team owns the suggestion index.

    Pass 2, measure. All numbers here are illustrative.

    • Typing responds visibly within 200 ms for 75% of real visits (field data).
    • Suggestions appear within 500 ms of the last keystroke on a throttled connection (lab test).
    • The enhancement script adds at most 10 KB compressed (build check).
    • A keyboard-only shopper can pick a suggestion (manual test).

    Pass 3, data.

    Data Owner Freshness Where it lives
    Text in the box, highlighted suggestion The shopper Live Component state
    Submitted query The shopper Until the next search The URL (/search?q=...), so a results page can be shared
    Suggestions for a query The suggest API A few seconds stale is fine A per-visit cache keyed by the trimmed query

    Pass 4, interface. Drawing the states shows what the screen can be, including states nobody sketched.

    Figure 2. States of the suggestion list

    stateDiagram-v2 accTitle: States of the search suggestion list accDescr: The list starts idle, moves to waiting while the shopper types, then to loading, and ends in showing, empty or error. A new keystroke from any of those returns it to waiting, and Escape returns it to idle. [*] --> Idle Idle --> Waiting: shopper types Waiting --> Waiting: another keystroke Waiting --> Loading: pause long enough Loading --> Showing: suggestions arrive Loading --> Empty: valid query, no matches Loading --> Error: request fails Showing --> Waiting: shopper types Empty --> Waiting: shopper types Error --> Waiting: shopper types Showing --> Idle: Escape or submit

    How to read this: "Waiting" is the pause before a request is sent (the debounce below). "Error" is a state of the list only: it must never stop the shopper from pressing Enter and getting the full results page.

    For the semantics, the W3C's ARIA Authoring Practices Guide (APG) defines a combobox pattern. The element that serves as the input has role combobox and sets aria-expanded to true while the popup is visible. Its aria-controls refers to the popup, which can be a listbox. When an option in the popup is focused, DOM focus stays on the input and aria-activedescendant refers to that option. Down Arrow moves focus into the popup, Escape dismisses it and Enter accepts a suggestion.

    Pass 5, delivery. The box should work before any JavaScript runs, which is the progressive-enhancement idea from the requirements lesson: a plain GET form to /search, with a script that adds suggestions on top. That keeps the weight down and leaves a working path when the script fails.

    Pass 6, failure: where a skipped pass shows up

    Suppose the first version is written the obvious way: on every keystroke, call the API and show the answer.

    async function naive(query) {
      const items = await search(query);   // search() calls the suggest API
      show(items);
    }

    The shopper types "sh", then "shoe" 30 ms later. Nothing guarantees answers return in the order sent. To reproduce it, use a fake search whose delay depends on the query (illustrative delays: the short query matches more products, so assume it is slower).

    const delays = { sh: 120, sho: 60, shoe: 20 };    // milliseconds, illustrative
    const search = async (query, signal) => {
      await new Promise((resolve, reject) => {
        const t = setTimeout(resolve, delays[query] ?? 10);
        signal?.addEventListener("abort", () => {
          clearTimeout(t);
          reject(new DOMException("aborted", "AbortError"));
        });
      });
      return [query + "-result"];
    };

    Calling naive("sh"), waiting 30 ms, calling naive("shoe") and letting both finish printed this on Node v26.12.1 (the show function logged each answer):

    naive shows: shoe-result
    naive shows: sh-result
    naive final screen: sh-result (typed: shoe)

    The screen shows the answer to the older, shorter query.

    Figure 3. Responses arriving out of order, and the fix

    sequenceDiagram accTitle: Out-of-order search responses and the abort fix accDescr: The shopper types sh, then shoe. Without a guard the slow response for sh arrives last and overwrites the right results. With abort and a guard the first request is cancelled, and any late answer is ignored. participant U as Shopper participant P as Page script participant A as Suggest API U->>P: types "sh" P->>A: request "sh" U->>P: types "shoe" P->>A: request "shoe" A-->>P: answer for "shoe" (fast) P->>U: shows shoe results A-->>P: answer for "sh" (slow, late) Note over P: without a guard, "sh" overwrites "shoe" Note over P: with abort and a guard,
    "sh" is cancelled and ignored

    How to read this: time runs downward. The bug is not a slow server. It is two answers returning in a different order from the questions. The fix is to let only the newest request update the screen.

    Three tools fix it, and each answers a different question.

    • Debounce reduces how many requests start. MDN defines debouncing as discarding operations that occur too close together during an interval and consolidating them into a single invocation. Every millisecond of debounce is added to the shopper's wait.
    • Abort cancels the request nobody wants. MDN says an AbortController allows you to abort web requests, and that when abort() is called the fetch() promise rejects with a DOMException named AbortError. That error is intentional, so the code treats it as "ignore", not "show an error".
    • A guard ensures that a response which was not cancelled (for example, from a wrapper that ignores the signal) cannot overwrite a newer one.

    Here is the controller with all three. search is injected, so it is testable without a network.

    function createTypeahead({ search, onResults, onError, delayMs = 50 }) {
      let timer;
      let controller;
      return function onInput(raw) {
        const query = raw.trim();
        clearTimeout(timer);
        controller?.abort();                     // cancel anything in flight
        if (query === "") {
          onResults(query, []);
          return;
        }
        timer = setTimeout(async () => {         // debounce
          const mine = new AbortController();
          controller = mine;
          try {
            const items = await search(query, mine.signal);
            if (controller !== mine) return;     // guard: a newer search owns the screen
            onResults(query, items);
          } catch (err) {
            if (err.name === "AbortError") return; // cancelled on purpose
            onError(query, err);
          }
        }, delayMs);
      };
    }

    Three runs on Node v26.12.1 with the fake search above, each reporting through onResults:

    1. Type "sh", "sho", "shoe" 10 ms apart, wait 200 ms, then clear the box. Output: results for "shoe": shoe-result, then results for "": (an empty list). Only the final query produced a request.
    2. Type "sh", wait 70 ms (its request is now in flight), then type "shoe". Output: only results for "shoe": shoe-result. The older request was aborted and never reached the screen.
    3. The same as run 2, but with a search that ignores the signal. Output: again only results for "shoe": shoe-result. Abort saves work; the guard keeps the screen correct.

    Two more failure cases belong in this pass:

    • The API answers with an error status. MDN says a fetch() promise only rejects when the request fails, for example from a network error, and does not reject on HTTP statuses such as 404 or 504. The code must check response.ok. Without that, an error body gets parsed as if it were suggestions.
    • The request is slow or never returns. Keep the input working and Enter available. A broken suggestion box must not block the shopper's real goal.

    Pass 7, decision.

    Field Decision record
    Context Shoppers want fast, forgiving search. Responses can arrive out of order, and another team owns the index.
    Decision A plain GET form plus a small script: 200 ms debounce (illustrative), abort plus guard, a per-visit cache, APG combobox semantics, the query in the URL.
    Good consequences Works without the script; no stale overwrites; cheap on the API.
    Bad consequences Added wait from the debounce; two code paths to test; the cache can show a removed suggestion.
    Revisit when Field INP leaves the good range, or the suggestion error rate passes an agreed level.

    Problem B: a filtered product list (the realistic example)

    The setting. A category page shows a grid of products with filters for size, colour and price, a sort control and a way to see more. Shoppers arrive from search engines and shared links, mostly on phones, and a category can hold thousands of products (illustrative). The request reads: "Add filters, and make it infinite scroll."

    Pass Result for the product list
    1. Scope Journeys: narrow a category by filters, sort it, open a product, come back to the same place. Out of scope: saved filter sets, comparison. A shared link must show the same filtered result to the recipient.
    2. Measure Illustrative sentences: the first product row is visible within 2.5 s for 75% of real mobile visits (web.dev's good Largest Contentful Paint threshold); a filter responds visibly within 200 ms; layout does not jump as images arrive (web.dev's good Cumulative Layout Shift is 0.1 or less).
    3. Data Filter, sort and position are state a shared link must reproduce, so they live in the URL. Batches come from a catalogue API and may be a few seconds stale.
    4. Interface States: loading, filtering, empty ("no products match, clear a filter"), partial, error (retry without losing filters), end of list. Filters are native form controls.
    5. Delivery Category HTML is the same for every shopper, so build it ahead or cache it, then hydrate the filters.
    6. Failure A failed batch must not look like "no more products". A filter change during a slow request must not append the old results. Back and reload must restore the same view.
    7. Decision "Load more" button, infinite scroll or numbered pages? See below.

    Where passes 3 and 5 interact. Because filters live in the URL (pass 3), each filtered view is an address. Shared links and the back button work, and the first batch can arrive as ready-made HTML. The hard part is caching, because the number of combinations can be large. One defensible split, as a design choice and not a rule: prepare the unfiltered category ahead of time and serve filtered views from a cached server response. The Rendering Strategies and HTTP Caching lessons take the options in turn.

    The decision. The request said infinite scroll. The framework turns that instruction into a question whose criteria come from earlier passes.

    Figure 4. Choosing how a list reveals more items

    flowchart TD accTitle: Choosing between numbered pages, a load more button and infinite scroll accDescr: A decision tree. If people must find, return to or share an exact position, use numbered pages. If they need content below the list, use a load more button. If every earlier pass is satisfied by auto-loading, consider infinite scroll with a button fallback; if unsure, use the button. Q1{"Must people find, return to
    or share an exact position?"} -->|"yes, often"| P["Numbered pages
    (position in the URL)"] Q1 -->|"sometimes"| Q2{"Do people need content
    below the list, such as the footer?"} Q2 -->|"yes"| L["Load more button
    (person chooses when)"] Q2 -->|"no"| Q3{"Is every other pass
    satisfied by auto-loading?"} Q3 -->|"yes"| I["Infinite scroll
    with a button fallback"] Q3 -->|"not sure"| L

    How to read this: each question is a requirement from an earlier pass. The path ends at "load more" whenever you are unsure, because a button is the choice that is hardest to get wrong.

    For this page, shoppers return from a product to the same place (pass 1), the position must survive reload (pass 3), the footer holds delivery and returns information (pass 1), and keyboard users must reach it (pass 4). Those answers point to a "Load more" button with the loaded count in the URL. A feed whose only job is endless browsing, with no footer and no exact positions, can justify infinite scroll.

    If you choose it, know the mechanism. IntersectionObserver is the usual tool. MDN says its callback runs when a target element's visibility has crossed one or more thresholds. A design that relies on it needs a case for "the sentinel never crosses", which the In practice section shows. The APG also defines a feed pattern: the container has role feed, each item role article with aria-posinset and aria-setsize (-1 if the total is unknown), and the page code must set aria-busy while articles are added and clear it afterwards. That is more work than a button, and belongs in the decision record as a cost.

    Using the framework: depth and time

    Scale the passes to the risk. For a small, reversible change in one component, a bulleted answer per pass in the ticket is enough. For a new feature with its own states and data, write the seven artifacts, each short, and have one other person read them. For a new journey, a new rendering approach or anything hard to undo, add a prototype or a measurement for the riskiest pass. A one-page worksheet is enough to run any of these:

    Request:
    1 Scope     journeys / out of scope / assumptions
    2 Measure   3 testable sentences / budget limits / how checked
    3 Data      what, owner, freshness, where state lives
    4 Interface states / keyboard / screen reader
    5 Delivery  where HTML comes from / what ships / what is cached
    6 Failure   slow, wrong, late, never / response for each
    7 Decision  choice / cost / revisit when

    The other side of this topic: on the back end the same habit leads to load numbers, an API and a data model. The front end has no capacity to estimate, but a device and a network to describe.

    In practice: an order list that stops loading on big monitors

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    An internal order-management tool lists orders for a support team. A ticket asked for "infinite scroll on the orders list". It shipped in a day: the first 20 orders (illustrative) load, an element below the list is observed with IntersectionObserver, and when it comes into view the next 20 load. A week later one agent reports: "I only ever see 20 orders." Colleagues on laptops never see it.

    Starting architecture

    Figure 5. The orders list as first built

    Starting architecture: a list, a sentinel element and an observerSupport agent to Orders list; Orders list to Sentinel (last row); Sentinel to Observer (crossing); Observer to Orders API (next 20)BROWSERSupport agent: large monitorSupport agentlarge monitorOrders list: first 20 rowsOrders listfirst 20 rowsSentinel: below the last rowSentinelbelow the lastrowObserver: fires on crossingObserverfires on crossingOrders API: 20 per requestOrders API20 per requestlast rowcrossingnext 20

    How to read this: loading depends on one event, the sentinel crossing into view. If it is already in view when the first rows render, the design has nothing to rely on to request more.

    Options considered

    A review replays the seven passes and finds a gap in pass 4 (no state for "the list is shorter than the screen") and in pass 6 (no fallback when the automatic trigger does not fire).

    Option What it fixes What it costs Risk
    Re-check after every batch and keep loading until the sentinel is out of view The tall-monitor case A loop that must stop at the end of the list A bug here floods the API
    A "Load more" button No dependency on a crossing event One click per batch Slightly slower scanning
    Numbered pages with the page in the URL Agents can find and share an exact page More clicks to go deep A bigger change to the screen

    The decision

    Field Decision record
    Context Agents look for specific orders, share links in chat and work on screens of very different heights.
    Decision Numbered pages, page number in the URL, 50 rows per page (illustrative), explicit loading, empty and failed states.
    Good consequences A tested path on every screen size; shareable links; no observer to reason about.
    Bad consequences More clicks to go deep into a long list.
    Revisit when Agents report that paging slows them down.

    Rollout and verification

    1. Reproduce first. Open the old list in a window taller than 20 rows and confirm that loading stops. Keep it as a test case.
    2. Ship behind a flag for the support lead and one agent.
    3. Test the states. Cut the network in the developer tools and confirm the failed-page state. Load a filter that matches nothing and confirm the empty state looks different.
    4. Check keyboard use. Operate the pagination controls without a pointer.

    The 2 a.m. failure

    Two days after release, the orders API has a short outage and returns HTTP 500 for page 2. The new list shows "No orders found" to several agents, and one tells a customer there is no order. The cause: the fetch wrapper parses the response body as JSON and reads items, which is missing from the error body, so the code treats it as an empty list. A fetch() promise does not reject on an HTTP error status (MDN) and nothing checked response.ok.

    First three checks: the network panel for the failing request and its status; the error-reporting dashboard filtered by release; the wrapper code, to see whether it checks response.ok. Mitigation: turn the flag off to return to the previous list while the wrapper is fixed. Follow-up: treat a non-OK status as the Error state with a retry button, add a test that a 500 response does not render the empty state, and add "failed is not empty" to the review checklist. This is the failure pass, applied late.

    Architect's notes

    • Run the passes before opening the editor. Fifteen minutes of questions is cheaper than rewriting a component.
    • Keep the artifacts small and in the ticket. A framework that needs a meeting gets skipped.
    • Measure before you promise. A target with no device and no network class is a hope.
    • Decide where state lives in pass 3. Moving it later changes every component that reads it.
    • Design the failed state separately from the empty state. They need different words and different actions.
    • Name the revisit trigger. A decision with no trigger quietly becomes permanent.

    Common mistakes

    • Starting at pass 5. Choosing a framework before scope and targets. Fix: write passes 1 to 3 first.
    • Skipping pass 6. The happy path ships and the first real network produces a bug report. Fix: for every call, write the response to slow, wrong, late and never.
    • Artifacts that cannot be checked. "Responsive" is not a measure. Fix: use the sentence template.
    • Ignoring the loops. Finding a failure in pass 6 and not updating pass 4. Fix: go back and change the earlier artifact.
    • Copying a pattern without its edge cases. Infinite scroll, debounce and caches each have a failure the tutorial skips. Fix: write it in pass 6.

    Key takeaways

    • A framework is a fixed order of questions, each with one artifact. It does not decide for you; it makes sure decisions get made.
    • The seven passes: scope, measure, data, interface, delivery, failure, decision.
    • The order follows cost of change, with two common loops: targets that change scope, and failures that change the interface.
    • Where state lives is a design decision. Filters, sort and position that a link must reproduce belong in the URL.
    • Race conditions belong to the failure pass. Debounce reduces requests, abort cancels the unwanted one, and a guard stops a stale answer overwriting a fresh one.
    • fetch() does not reject on HTTP error statuses. Check response.ok, and keep "failed" distinct from "empty".
    • Prefer existing accessibility patterns, such as the APG combobox, over invented semantics.
    • Scale the depth to the risk.
    • End with a decision record that names the cost and the trigger to revisit.

    Practice exercises

    Exercise 1, starter (30 min): Take the request "add a dark mode toggle". Fill in the one-page worksheet and list at least three states and one failure case for pass 6.

    Exercise 2, intermediate (2 hours): Implement createTypeahead with a fake search that has per-query delays. Write a test that types two queries so the responses arrive in reverse order, and show it fails when you remove the guard and the abort.

    Exercise 3, intermediate (2 hours): For Problem B, decide which filter and sort combinations should be ready-made HTML and which rendered on demand, and state what you would measure to check the split.

    Exercise 4, advanced (half a day): Pick a list or search feature you use. Reverse-engineer its seven artifacts from how it behaves, find one pass the builders seem to have skipped and write a decision record proposing a fix.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments