What you'll learn: By the end of this lesson you will be able to compare front-end options without arguing about taste. You will name the decision and its drivers, list real options, choose criteria from requirements, screen out dealbreakers before scoring, check how sensitive the result is, separate lab evidence from field evidence, and write a short decision record that says when to look again.
Choosing luggage is a trade-off. A carry-on is quick through the airport but holds little. A large case holds everything but costs time and money at the desk. Nobody asks "which is the best bag?". They ask "best for this trip, with these rules, and how bad is it if I am wrong?".
Front-end decisions are the same. "Should we use server rendering?" has no answer until you say for which page, which shoppers and which targets. This lesson is the dedicated treatment of pass 7 of the design framework: how to compare options and keep the reasons.
The running example is the online store from this course. The small example is a product image. The larger one is the reviews section of the product page.
The core idea
A trade-off exists when improving one quality costs another. Every real option gives something up, so an option with no downside has been described badly. Trade-off analysis is a short, repeatable way to compare options against the criteria your requirements already gave you, and to write the result down.
Three rules carry most of the value:
- Criteria come from requirements, not from the options. If the requirements lesson gave you targets for speed, accessibility and freshness, those are your criteria. Comparing options on what each one happens to be good at tests the options, not your product.
- Dealbreakers are not scores. A requirement that must hold ("no layout shift once the shopper starts reading") screens options out. It is not a column to trade against cheap bundle size.
- A decision is a bet with a trigger. Write what you assumed and what would make you look again.
Figure 1. The method
and its drivers"] --> O["2 List real options"] O --> C["3 Pick criteria
from requirements"] C --> E["4 Gather evidence"] E --> M["5 Compare"] M --> R["6 Check
reversibility"] R --> W["7 Record and
set a trigger"] M -.->|"nothing survives"| O
How to read this: the work runs left to right. The dotted arrow is the useful failure: if every option breaks a dealbreaker, you add or reshape options instead of weakening the requirement.
The method step by step
- Name the decision and its drivers. One sentence: "How do we load the reviews on the product page?" Add the forces that make it a decision now: a target, a redesign, a new team.
- List real options. Two to four, including the plainest one, which sets the baseline. An option nobody would pick is filler.
- Pick criteria from requirements. Use the sentences you already wrote: the first view appears quickly, the layout stays still, data is fresh within minutes, the team can maintain it.
- Gather evidence. For each criterion, find the cheapest thing that would change your mind: a spec, a vendor document, a small prototype, field data you already have.
- Compare. Apply dealbreakers, then (only if you need it) score what remains. The next sections show how.
- Check reversibility. Ask how hard it is to undo. A choice behind an interface you can swap later deserves less analysis than a URL structure or a data format other teams will build on.
- Record and set a trigger. Short, written, with the cost and the condition for revisiting.
A minimal example: eager or lazy images
A product gallery shows one large main image and eight thumbnails further down. The decision: should these images use loading="lazy"?
MDN describes the two values: eager loads the image immediately, regardless of whether it is within the viewport (the default), while lazy defers loading until the image reaches a calculated distance from the viewport, as defined by the browser. The trade-off appears when you compare the images against different criteria.
| Image | Gains from lazy |
Costs of lazy |
Choice |
|---|---|---|---|
| Main image, in the first view | Fewer bytes up front on a path where nobody scrolls first | Loading starts later, which delays the element most likely to be the Largest Contentful Paint | Eager |
| Thumbnails, below the first view | The browser can skip them if the shopper never scrolls | A short delay if the shopper scrolls fast | Lazy |
web.dev's LCP guidance is direct about the first row: never lazy-load your LCP image, because that always leads to unnecessary resource load delay. It also suggests fetchpriority="high" on an image likely to be the LCP element, and warns that giving high priority to more than one or two images makes it unhelpful. The thumbnail row is the opposite case. The same attribute, loading="lazy", is the right answer for one image and the wrong one for another, which is why "always lazy-load images" is not a decision.
One criterion applies to both: space. MDN says explicit width and height are recommended for all images to avoid layout shift, and are especially important for lazy-loaded ones, since the browser can compute the aspect ratio and reserve the space before the image arrives.
<img src="/img/boot-main.jpg" width="800" height="800"
fetchpriority="high" alt="Walking boot, side view">
<img src="/img/boot-thumb-2.jpg" width="120" height="120"
loading="lazy" alt="Walking boot, sole">
The first image has no loading attribute, so it is eager by default. Both reserve their space. See LCP optimization for the full path from request to paint.
Comparing options: dealbreakers first, then scores
Once you have options and criteria, the order of operations matters more than the arithmetic.
Figure 2. How to compare
dealbreaker?"} B -->|"yes"| X["Remove it
or reshape it"] B -->|"no"| S["Score on weighted criteria"] X --> N{"Any option left?"} N -->|"no"| A N -->|"yes"| S S --> T["Change each weight by one"] T --> F{"Does the
winner flip?"} F -->|"no"| G["Decide"] F -->|"yes"| H["Get evidence on the
criterion that flips it"] H --> G
How to read this: a score is a summary, not a proof. The sensitivity step shows whether the ranking is a real difference or an accident of the weights.
Here is the reviews decision. The reviews section shows the written reviews under the product details. There are three options:
- A. In the page HTML. The reviews are part of the prepared page.
- B. Fetch after load. The page loads first, and the browser requests the reviews immediately.
- C. Fetch near the viewport. The browser requests them when the section is about to be seen.
The criteria are four of the requirements: little work in the first view, fresh reviews, a simple implementation and a stable layout. The weights (1 to 5) and the scores (1 to 5) below are illustrative, made up for this lesson. Real values would come from your requirements and your evidence.
| Criterion (weight) | A in the HTML | B after load | C near viewport |
|---|---|---|---|
| First-view cost (3) | 3: more to prepare and send | 5: nothing extra | 5: nothing extra |
| Freshness (2) | 2: as old as the last build or cache | 5: current | 5: current |
| Simplicity (2) | 4: no extra requests | 4: one request | 2: needs visibility logic |
| Layout stability (3) | 5: space already there | 2: content arrives after layout | 3: arrives off screen, if space is reserved |
The code is small. It applies a floor (a dealbreaker), ranks the survivors and then moves each weight by one to see whether the winner changes.
function rank(options, weights, floors = {}) {
const kept = options.filter((o) =>
Object.entries(floors).every(([criterion, min]) => o.scores[criterion] >= min));
return kept
.map((o) => ({
name: o.name,
total: Object.entries(weights).reduce((sum, [c, w]) => sum + w * o.scores[c], 0),
}))
.sort((a, b) => b.total - a.total);
}
function winnerFlips(options, weights, floors) {
const base = rank(options, weights, floors)[0].name;
const flips = [];
for (const c of Object.keys(weights)) {
for (const delta of [-1, 1]) {
const w = { ...weights, [c]: weights[c] + delta };
if (w[c] < 0) continue;
const [top, next] = rank(options, w, floors);
const change = `${c} ${delta > 0 ? "+1" : "-1"}`;
if (top.total === next?.total) flips.push(`${change} -> tie`);
else if (top.name !== base) flips.push(`${change} -> ${top.name}`);
}
}
return flips;
}
Run with the weights and scores in the table (stability floor of 3), on Node v26.12.1, it printed:
no floors: B fetch after load=39, C fetch near viewport=38, A in the page HTML=36
stability>=3: C fetch near viewport=38, A in the page HTML=36
flips: firstView -1 -> tie; freshness -1 -> A in the page HTML; simplicity +1 -> tie; stability +1 -> tie
Two things happened. Without the floor, the weighted total picks B, which fails the requirement that content must not jump under a reading shopper. A high score on cheap first-view cost bought its way past a dealbreaker. With the floor applied, C leads A by only two points. One single-step change, freshness weight 2 to 1, makes A win outright (34 against 33), and three more (first-view weight 3 to 2, simplicity weight 2 to 3, stability weight 3 to 4) leave C and A exactly level at 33, 40 and 41. The honest conclusion is "C, narrowly, and the margin depends on how much freshness is worth". That points at the criterion to test, not at a better formula.
Evidence: what you measured, what you read, what you guessed
A score is only as good as the claim behind it. Label the source of each cell: measured (you ran it), documented (a spec or official page says so) or guessed. Guesses are allowed, but a guess that decides the outcome needs a test.
For performance claims the trap is the kind of data. web.dev defines lab data as determined by loading a page in a controlled environment, and field data as determined by monitoring all users who visit a page. They differ because lab tests use one device and one network, usually a cold cache, and do not capture real behaviour such as scrolling, while field data reflects actual devices, networks and locations. A lab result can show that an option is possible. Whether it is good for your shoppers needs field data, read at the 75th percentile and segmented by device, as the Core Web Vitals guidance recommends. See Core Web Vitals for the metrics.
For large choices there are formal methods. The Software Engineering Institute describes its Architecture Tradeoff Analysis Method (ATAM) as a method for evaluating software architectures against quality-attribute goals, and a utility tree as a hierarchic model of the driving architectural requirements. A reviews widget needs no ceremony, but the shape is the same: quality goals first, then risks and trade-offs.
Reversibility and cost of change
Spend analysis in proportion to how hard a choice is to undo. A loading strategy behind a component boundary is cheap to revisit. A URL scheme, a public data contract or how state is shared across teams is not, because other people build on it. The rendering strategies lesson covers a high-cost choice: it touches caching, deployment and every page template.
Recording the decision
Michael Nygard's widely used note on architecture decision records says each record has a title, a context ("the forces at play"), a decision, a status (such as proposed or accepted) and consequences, and that the whole document should be one or two pages. If a decision is reversed, he says to keep the old record and mark it superseded. That is the base format.
This course adds two lines that suit front-end decisions, because they are the ones that rot quietly:
| Field | Reviews section |
|---|---|
| Context | Reviews should be fresh, must not move the layout, and the first view must stay light. |
| Decision | Option C: fetch when the section nears the viewport, with its space reserved. |
| Consequences | Good: nothing extra in the first view, reviews are current. Bad: visibility logic to test; a short empty moment on fast scrolls. |
| Assumptions (added) | The section is below the first view. Every product has reviews. The weights above. |
| Revisit when (added) | The layout changes so the section is visible at load, or field layout-shift data for the product template leaves the good range. |
The other side of this topic: back-end design makes the same move at the system level, for example in CAP and PACELC, where the lesson is to name which quality you give up, not to hunt for a free option.
In practice: the reviews section that began to jump
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 or name in it is an illustrative assumption.
The situation
The store team adopted option C from the table above. Reviews load when the section approaches the viewport, and the section has a fixed minimum height so nothing moves when they arrive. Field data for the product template is healthy for two months. Then the catalogue grows with new products that have no reviews yet.
Starting architecture
Figure 3. Reviews as first built
How to read this: the HTML never contains reviews. A visibility check triggers the request, and the box has a fixed height so late content does not push anything.
Options considered
After a rise in layout shift on new products, the team weighs three fixes: a same-height placeholder when a product has no reviews; a smaller reserved height for products with few reviews; or a move to option A.
The decision
Keep option C and show a same-height placeholder ("No reviews yet") whenever the list is empty. It is the smallest change and keeps the earlier trade-off intact. The record's assumption "every product has reviews" was wrong, so it is corrected and given a test.
Rollout and verification
- Reproduce. Load a product with no reviews on a throttled connection and watch whether content below the section moves.
- Add the empty-case test to the component tests.
- Ship behind a flag to part of the traffic and compare field layout-shift data for the product template, segmented by device.
- Update the record and mark the old text superseded.
The 2 a.m. failure
Nothing pages at 2 a.m. in this case, which is the point: the field layout-shift number for the product template creeps up over a week and a product manager asks why pages feel jumpy on phones. The cause is the unrecorded assumption. For products with no reviews the API returns an empty list, the code removes the fixed-height box, and everything under it moves up. web.dev warns that removing space set aside for elements can cause just as much layout shift as inserting content, and advises showing a placeholder instead of collapsing.
Figure 4. What the shopper sees when the list is empty
content below moves up Note over P: fix keeps a placeholder
with the same height
How to read this: nothing is slow and nothing errors, so no alert fires. The cost shows up only in field data, which is why the decision record names the metric to watch.
First three checks: field layout-shift data for the product template, split by whether the product has reviews; the page's behaviour for an empty list on a throttled connection; and the decision record's assumptions. Mitigation: show the placeholder. Follow-up: add the empty-case test, add "has reviews" to the record's assumptions, and set a field-data alert for the template.
Architect's notes
- Write the dealbreakers before the options. It is hard to be objective about a floor once you like an option.
- Look for the closest second place. If the margin is thin, say so and test the criterion that decides it.
- Prefer reversible choices when evidence is weak.
- Put assumptions in the record, not in people's heads. Most failed decisions failed on an assumption nobody wrote down.
- Revisit on a trigger, such as a metric, a redesign or a new team.
Common mistakes
- Scoring before screening. A high total can hide a failed dealbreaker. Fix: apply floors first.
- Choosing criteria after the favourite is clear. Fix: write the criteria before you list the options.
- Treating weights as facts. Fix: change each weight by one and watch the winner.
- Using lab data for a field claim. Fix: say which kind of data each cell rests on.
- Leaving no trigger to revisit. Fix: add a "revisit when" line to every record.
Key takeaways
- A trade-off is a gain in one quality paid for by another. An option with no stated cost has not been analysed.
- Criteria come from requirements. They are not borrowed from what the options advertise.
- Dealbreakers screen options out before scoring.
- Run a sensitivity check. If one-step changes in weights flip the winner, collect evidence for the flipping criterion.
- Label evidence as measured, documented or guessed, and use field data for claims about real users.
- The same attribute can be right for one element and wrong for another: do not lazy-load the LCP image, do lazy-load the thumbnails.
- Match effort to reversibility.
- Record the decision with context, consequences, assumptions and a revisit trigger.
Practice exercises
Exercise 1, starter (30 min): Pick a page you know. For each image, decide eager or lazy, with a one-line reason tied to the first view. Say which image you think is the LCP element and how you would confirm it.
Exercise 2, intermediate (1 hour): Run the rank and winnerFlips code with your own scores for the reviews options. Change a score you are least sure of and report what happens.
Exercise 3, intermediate (1 hour): Write a decision record for "should filter state live in the URL or in a store?" with context, two options, consequences, assumptions and a revisit trigger.
Exercise 4, advanced (2 hours): Take a front-end decision you disagree with. Reconstruct its likely drivers and dealbreakers, list two options nobody considered, and say what field data would have caught the problem early.
Related reading
In this course:
- A Repeatable Framework for Front-End Design Problems — the seven passes, of which this lesson is pass 7.
- Frontend Requirements: Users, Devices and Networks — where criteria and targets come from.
- Rendering Strategies — a high-cost-of-change decision to practise on.
- Optimizing Largest Contentful Paint — the image path in detail.
- Loading Performance and Core Web Vitals — the metrics behind the criteria.
Elsewhere on the site:
- CAP Theorem and PACELC — the same habit of naming what you give up, on the back end.
Official documentation
- web.dev: Optimize LCP — lazy-loading and priority advice for the LCP image.
- web.dev: Optimize CLS — reserving space and the cost of removing it.
- web.dev: Why lab and field data can be different — what each kind of data represents.
- MDN: the img element — the
loadingattribute andwidthandheight. - Michael Nygard: Documenting Architecture Decisions — the record format.
- SEI: ATAM collection — a formal trade-off evaluation method.