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.
- 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.
- 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.
- 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
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
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
"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
AbortControllerallows you to abort web requests, and that whenabort()is called thefetch()promise rejects with aDOMExceptionnamedAbortError. 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:
- Type "sh", "sho", "shoe" 10 ms apart, wait 200 ms, then clear the box. Output:
results for "shoe": shoe-result, thenresults for "":(an empty list). Only the final query produced a request. - 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. - The same as run 2, but with a
searchthat ignores the signal. Output: again onlyresults 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 checkresponse.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
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
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
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
- Reproduce first. Open the old list in a window taller than 20 rows and confirm that loading stops. Keep it as a test case.
- Ship behind a flag for the support lead and one agent.
- 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.
- 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. Checkresponse.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.
Related reading
In this course:
- Frontend System Design: The Big Picture — the layers and the decisions an architect owns.
- Frontend Requirements: Users, Devices and Networks — sentence template, budgets and states.
- Trade-Off Analysis for Front-End Decisions — how to compare the options in pass 7.
- Rendering Strategies — pass 5, where HTML comes from.
- HTTP Caching for the Front End — pass 5, what is cached and for how long.
- Loading Performance and Core Web Vitals — pass 2, how the metrics are measured.
- The Event Loop and the Main Thread — why long tasks hurt responsiveness.
Elsewhere on the site:
- System Design: A Repeatable Approach — the back-end eight-step framework.
- System Design: Requirements — functional and non-functional requirements for servers.
Official documentation
- W3C APG: Combobox pattern — roles, states and keyboard behaviour.
- W3C APG: Feed pattern —
feed,article,aria-posinset,aria-busy. - MDN: AbortSignal and AbortController — cancelling
fetch()and theAbortError. - MDN: Window.fetch() and Debounce — when the promise rejects and when it does not; what debouncing means.
- MDN: IntersectionObserver — observing intersection changes and thresholds.
- web.dev: Web Vitals, Performance budgets 101 and Rendering on the Web — thresholds, percentile, budgets and rendering trade-offs.