How API Shape Drives UI Complexity
Pagination style decides whether infinite scroll is even possible. Granularity decides whether a screen is one request or nine. The error model decides whether you can say anything useful when it fails.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How much of my frontend complexity is actually a consequence of the shape of the API I was handed?
Someone wants a screen that loads once, shows what is there, and — when something goes wrong — tells them which field they got wrong rather than that the request was bad.
The API is what it is. The frontend adapts: fetch whatever endpoints exist, join the results in the client, and write the error messages ourselves.
Offset pagination over a collection that is being written to gives you duplicates and skipped rows. An infinite scroll built on it shows the same row twice and silently drops others — and it looks perfectly fine in every demo (Pagination From the Interface Backwards).
- Offset pagination over a collection that is being written to gives you duplicates and skipped rows. An infinite scroll built on it shows the same row twice and silently drops others — and it looks perfectly fine in every demo (Pagination From the Interface Backwards).
- Twenty rows, each needing its author, becomes twenty-one requests. On a high-latency connection the screen's load time is set by round trips, not by bytes, and no amount of frontend optimisation recovers it (Reading a Network Waterfall).
- An error model that is a status code plus a prose string means you cannot attach a message to the field that caused it. The best you can do is a toast that says something close to "Bad Request" (Errors People Can Actually Perceive).
- An endpoint that returns four hundred kilobytes so you can render three fields makes every phone pay parse and transform cost for data the user will never see (The Real Cost of JavaScript).
- A screen that needs three fields from three endpoints has three chances to fail, three loading states, and a "partly loaded" state nobody designed (Loading, Error, Empty — The States You Did Not Render).
- Rows without stable identifiers cannot be keyed, so every refresh replaces DOM nodes instead of updating them — and focus, selection and scroll position go with them (Reconciliation and Keys).
- No representation of what this user may do, so the client re-implements the permission rules and drifts from them (Authorization-Aware UI).
- An enum with no documented handling for unknown members means the next status the backend adds crashes every client already in the field (Long-Lived Clients and Version Skew).
What is actually happening
In the browser, not in the framework.
- A client does three things with an API: turn a screen into a set of requests, hold the results as state, and render them. Every property of the contract makes exactly one of those three easier or harder, and that is the whole of this lesson.
- Pagination style determines what "the next page" means while the collection is changing. Offset addresses a position in a list that moves; a cursor addresses a place in a stable ordering. That difference decides which UI patterns are correct, not merely which are convenient (Cursor Pagination: An Opaque Bookmark, Not a Position).
- Granularity determines request count, and request count determines latency on a high-round-trip network. Fetching nine small things in sequence is slow for a reason bandwidth cannot fix (Latency Is a Distribution, Not a Number).
- The error model determines whether an error can be localised. A machine-readable code plus a field path can be rendered next to the input that caused it; a human-readable sentence can only be shown somewhere generic (The Error Model: Structure Over Apology).
- Payload shape determines how much work happens on the main thread: bytes to parse, objects to allocate, and joins the client has to perform because the server did not (Over-Fetching and Under-Fetching).
- Capability fields determine whether authorization-aware UI is derivable or guessed.
{ id, title, can: { edit: false } }is a contract; re-deriving the rule client-side is a copy that will drift. - Freshness metadata determines whether a client cache can be honest. Without validators or explicit staleness, every cache decision is a guess (Stale-While-Revalidate).
- And the framing that matters most: you are a consumer of this contract, and consumers have requirements. An API designed without one is an API whose costs land in your codebase silently (Consumer-First Design).
What this makes the browser do
And which of it is avoidable.
- One connection's worth of scheduling per request, plus whatever the browser's prioritisation does with nine of them at once (Reading a Network Waterfall).
- JSON parsing on the main thread, in a task the user is waiting inside. Parse cost scales with payload size whether or not the fields are rendered.
- Allocating and retaining the parsed objects, which for an over-fetched list is memory held for the lifetime of the cache entry (Memory Leaks).
- Client-side joining, sorting and filtering that the server could have done — real CPU, on the slowest device in the population (Long Tasks).
- Reconciliation churn when identity is unstable, which turns an update into a replacement and costs style, layout and paint for rows that did not change (What a Mutation Costs).
Bring the screen, not the endpoint
The most useful shift in this lesson is to stop asking "what endpoints exist" and start asking "what does this screen need, in how many round trips, and what must it be able to say when part of it fails". Those are requirements, and they can be discussed before anything is built. "Can you add a field to /orders" is a patch on a design decision that was already made without you.
Written out as a pipeline, the client's work is short — and every step is made harder or easier by a contract property that was chosen somewhere else. That is what makes the table below worth bringing to a design review: each row is a UI capability a product manager will eventually ask for, and for some contracts the honest answer is "not possible", not "not yet implemented".
- 1Decompose the screen into requests
Decides how many round trips before anything is renderable.
fails by Fan-out: one request per row, so latency scales with row count (The N+1 Query Problem).
- 2Issue them
Parallel where independent, sequential where one response contains the next request's input.
fails by A contract that forces sequencing — an id you can only learn from a previous response (Composed APIs: Aggregating Other Services).
- 3Receive and parse
Turns bytes into objects on the main thread.
fails by Over-fetching, so a phone parses fields nothing renders (Over-Fetching and Under-Fetching).
- 4Adapt to a view model
One boundary converts the wire shape into what components consume.
fails by Being skipped, so the wire shape leaks into every component and every future change (What a Component Owes Its Caller).
- 5Key and cache
Stores by an identity that survives refetch, so updates are updates.
fails by Unstable ids, which turn every refresh into a remount and lose focus (Reconciliation and Keys).
- 6Render, including the failures
Shows what arrived and explains what did not.
fails by An error model with nothing to render — no code, no field, no distinction between retryable and permanent (An Error Taxonomy Clients Can Branch On).
- 7Page, refresh, mutate
Extends the list, revalidates, writes back.
fails by A pagination model that is not stable under concurrent writes (Pagination: Choosing How Lists End).
| The UI wants | Which contract property decides it | If the contract does not support it |
|---|---|---|
| Infinite scroll over a live feed | Cursor pagination with a stable ordering | Duplicated and skipped rows, with no error to notice (Cursor Pagination: An Opaque Bookmark, Not a Position) |
| "Page 7 of 43", jump to page | Offset pagination and a total count | You can offer "load more" and nothing else (Offset Pagination: Simple, Jumpable, and Lying Under Writes) |
| One screen, one spinner | Batching, or an endpoint shaped for the view | A waterfall of requests and a screen that arrives in pieces (Batch APIs and Partial Failure) |
| A message next to the wrong field | Structured errors with a field path and a stable code | A generic toast, and a form nobody can correct (Validation Errors: Feedback, Not Verdicts) |
| A disabled button with a reason | Server-computed capability fields on the resource | The client guesses, drifts, and hides controls instead of explaining them |
| Optimistic updates that reconcile | Stable ids and a mutation response containing the new state | A refetch after every write, and visible reverts (Optimistic UI) |
| Stale-while-revalidate | Validators or explicit freshness metadata | Every cache lifetime is a guess (Conditional Requests: ETags, 304 and 412) |
| A partly-loaded dashboard | A response that can express per-item success and failure | One failing widget takes the screen, or is silently blank (Partial Failure: When 3 of 5 Succeed) |
| A list that survives a backend release | Additive evolution and documented unknown-enum handling | The next status value crashes clients already in the field (Enum Evolution: The New Value That Broke Old Clients) |
Round trips, not bytes
The intuition that "the payload is small, so it is fast" comes from working on a low-latency connection. On a mobile link the fixed cost of a round trip dominates, and a screen that needs nine sequential requests spends most of its life waiting rather than transferring. This is why granularity — not compression, not payload trimming — is usually the highest-leverage contract property for perceived speed.
The waterfall below is the shape, not a measurement. What it shows is the part that matters: sequential dependencies do not overlap. Requests that must wait for a previous response to learn an identifier are additive; requests that can be issued together are not. A contract that lets you ask for what you need in one call collapses the entire staircase, and no client-side change can achieve the same thing.
- A: GET /users/:id × 20 (parallel, but queued) — the fan-out: bounded by connection scheduling, not by bytes
- B: GET /orders?expand=customer — one round trip, a somewhat larger payload
- C: batch fetch (one round trip) — two sequential trips instead of one, and no per-row fan-out
B wins on round trips and pays in payload; C is the compromise available when the API cannot embed. Which is right depends on the network and the device, which is why the measurement has to happen on a realistic profile rather than the one under your desk (Latency Is a Distribution, Not a Number).
const orders = await get('/orders?limit=20')
// One request per row. Twenty round trips, plus the first.
const withCustomers = await Promise.all(
orders.map(async (o) => ({
...o,
customer: await get(`/users/${o.customerId}`),
})),
)
// And when three of them fail?
// Promise.all rejects, and the screen shows nothing —
// including the seventeen rows that arrived fine.// Best: the contract lets the screen ask for what it needs.
const orders = await get('/orders?limit=20&expand=customer')
// Otherwise: batch, and model partial success explicitly.
const orders = await get('/orders?limit=20')
const ids = [...new Set(orders.map((o) => o.customerId))] // dedupe first
const { found, missing } = await post('/users:batch', { ids })
// Rows whose customer is missing still render, with an
// honest placeholder instead of an empty screen.The fan-out is not slow because it transfers more — it transfers less. It is slow because latency is paid per request and the failures are not independent. Deduplicating the identifiers and asking once converts twenty chances to fail into one, and makes partial success something the UI can represent rather than something Promise.all erases (Eager Loading and Batching).
An error model you can actually render
Ask what the interface has to do with a failure and the requirements fall out immediately. It must decide whether to retry. It must decide whether to show the message to the user or a generic one. It must decide where on screen to show it. And it must be able to distinguish "you typed the wrong postcode" from "the payment processor is down" from "you are not allowed to do this", because the correct response to each is different.
None of that is possible from a status code and a sentence. It becomes possible with three things: a stable machine-readable code the client can switch on, a field path when the error is about an input, and a flag for whether retrying could ever help. Everything else — the wording, the tone, the localisation — is then the frontend's job, which is where it belongs (Internationalization).
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Offset pagination over a live collection | Rows appear twice or vanish while scrolling | The offset addresses a position in a list that is being written to | Client-side de-duplication by id — which hides duplicates and cannot recover the skipped rows. The real fix is a cursor (Cursor Pagination: An Opaque Bookmark, Not a Position). |
| No batch endpoint | A staircase in the network panel; the screen is slow only on mobile | Granularity forces one request per row | Request coalescing and caching in the client, which reduces repeats but not the first load (Five Components, One Request). |
| Unstructured errors | Generic toasts; users cannot correct their own input | No code, no field path | String-matching the message, which breaks on the next copy edit or the next locale (An Error Taxonomy Clients Can Branch On). |
| No retryable flag | The client retries a permanent validation failure three times | The client cannot distinguish transient from terminal | A hand-maintained list of status codes and messages that is wrong for at least one case (Retryability: Telling Clients What To Do Next). |
| No capability fields | A control is shown, clicked, and rejected | The client guessed at permissions from a role | A copy of the authorization rules in the client, drifting from the server's (Authorization-Aware UI). |
| Unstable or absent ids | Focus jumps out of a row during a background refresh | Reconciliation cannot match old rows to new ones | Synthesising a key from content, which changes when the content does — reintroducing the same bug (Reconciliation and Keys). |
| Over-fetching | A slow interaction on mid-range phones only | Parse and transform cost proportional to a payload mostly unused | Parsing in a worker, which moves the cost rather than removing it (Web Workers and the DOM Boundary). |
| No partial-failure representation | A dashboard is entirely blank because one widget's source is down | The response is all-or-nothing | Separate requests per widget, which restores independence and reintroduces the fan-out (Partial Failure: When 3 of 5 Succeed). |
1{2 "error": {3 "code": "validation_failed", // stable, switchable, never localised4 "requestId": "req_9f2b41", // what the user quotes to support5 "retryable": false, // could retrying ever help?6 "message": "Some details need fixing.", // safe to show as a fallback7 "fields": [8 { "path": "shipping.postcode", "code": "format_invalid" },9 { "path": "items[2].quantity", "code": "out_of_stock", "max": 3 }10 ]11 }12}13 14// The client can now:15// - put a message on the postcode input and on row 316// - localise from the code, not from the message17// - not retry (retryable: false), and not offer a retry button18// - show requestId in the support link19//20// Compare what is possible with: 400 "Bad Request"path is what makes an error renderable next to the input that caused it, and it is the field most often missing. Without it the client is reduced to string-matching a message — a coupling that breaks the first time someone improves the wording (The Error Model: Structure Over Apology).
How to build it
Most important first.
- Bring the screen to the API conversation, not the endpoint. "This view needs these fields for these rows in one round trip" is a requirement an API owner can design against; "can you add a field" is not (Start With Requirements, Not Endpoints).
- Ask for cursor pagination for anything that changes, and reserve offset for the case the UI genuinely needs: page numbers, a total, a deep link to page seven (Offset Pagination: Simple, Jumpable, and Lying Under Writes).
- Ask for a structured error model: a stable machine code, a field path where applicable, and a message safe to show a user. Never parse prose, and never switch on a status code alone (Validation Errors: Feedback, Not Verdicts).
- Ask for capability fields on resources rather than replicating authorization rules in the client. The server already knows; making it say so is cheaper and cannot drift.
- Ask for stable identifiers on everything that will appear in a list, and use them as both render keys and cache keys (Query Keys and Invalidation).
- Ask for batching or a composite endpoint where a screen would otherwise fan out — or, when the fan-out is inherent to a service topology, for a layer that does it server-side (Backend for Frontend).
- Adapt at one boundary. A single module converts wire shapes into view models; components never see the wire shape. A contract change then edits one file rather than forty (What a Component Owes Its Caller).
- Tolerate additive change: unknown fields ignored, unknown enum members rendered as an explicit unknown state (Long-Lived Clients and Version Skew).
- Write the workarounds down as costs. A client-side join, a de-duplication pass over paginated results, a hand-maintained copy of a validation rule — each is a line item to take back to the API owners, not a private frontend tax (Consumer-Driven Evolution: Telemetry Before Breakage).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The error model directly determines whether an error can be attached to the input that caused it. A form-level "invalid input" for a fourteen-field form is close to unusable with a screen reader: there is no way to find the field, and no message associated with it (Errors People Can Actually Perceive).
- Pagination style determines which navigation patterns are available. A "load more" button is keyboard-operable, announceable and interruptible; an auto-loading infinite scroll with an unreachable footer is a well-known trap, and only a contract that supports counts and stable cursors lets you offer either honestly (Keyboard Operability).
- Announce what arrived — "20 more results loaded, 60 of 240" — which requires the contract to tell you a total, or to say honestly that it cannot (Live Regions and Announcement).
- Unstable identifiers destroy focus. If reconciliation replaces a row rather than updating it, focus inside that row is lost and the keyboard user is returned to the top of the document mid-task (Focus Management).
- Over-fetching becomes an accessibility problem through the main thread: a large parse inside the task handling an interaction delays not only the visual update but the accessibility tree update and the announcement that depends on it (Interaction Responsiveness).
- Capability fields let you render a disabled control with a reason rather than hiding it silently. "Export — you do not have permission on this report" is navigable and explains itself; a missing button explains nothing (Authorization-Aware UI).
What can go wrong
- A client-side join across two paginated collections, which cannot be done correctly at all: you can only join the pages you happen to have.
- A hand-rolled composite fetch where one of nine requests fails, leaving a screen that is mostly there with no coherent story about what is missing (Partial Failure: When 3 of 5 Succeed).
- Adaptation done inside components instead of at a boundary, so the wire shape leaks into the whole codebase and the next contract change touches everything.
- Infinite scroll on offset pagination, silently dropping rows. It is the worst class of bug in this lesson because nothing errors and the UI looks correct.
- A client re-implementing a server business rule — a price calculation, a validation constraint — to render a preview, and drifting from it over time.
- Retry logic added because the error model cannot distinguish "retry will help" from "this will never work", so the client retries a permanent validation failure (Retries, and the Duplicate Order).
- A batch endpoint that fails as a unit, converting nine independent failures into one total one — a worse outcome than the fan-out it replaced (Batch APIs and Partial Failure).
- Pages fetched while the underlying collection is being written to — the offset-pagination duplicate-and-skip case, which produces wrong output with no error.
- Several requests for one screen resolving in an order different from the one they were issued in, so a later render is built from an earlier response (Out-of-Order Responses).
- A batch response with per-item outcomes arriving while the client has already optimistically updated some of those items (Optimistic UI).
- A refetch returning pre-update state because it was issued before a mutation landed, visibly reverting the user's change (Rollback and Reconciliation).
- Two components requesting the same resource simultaneously, which is a deduplication problem the contract can make easier or harder (Five Components, One Request).
- Over-fetching is an accidental disclosure mechanism. Fields the UI never renders are still in the response, and the response is readable in the network panel by anyone with the session — internal notes, other users' details, cost prices (The Browser Security Model).
- Capability fields must be computed by the server for the current user. A client that derives permissions from a role string it was given is not enforcing anything; the endpoint must re-check regardless (Broken Access Control (IDOR / BOLA)).
- Error messages designed for developers reach users. A stack trace, a query fragment or an internal hostname in an error body is visible in devtools and often ends up rendered on screen (Not Leaking Your Internals).
- An endpoint shaped for one screen can still be called with any parameters. The client's constraints — a page size, a filter, a sort — are UI conventions, not server limits (Unbounded Collections: The Anti-Pattern With a Fuse).
- Echoing the whole object you received back on update is how a client participates in mass assignment: fields the user should never control travel back up because the client did not narrow them (Mass Assignment and Over-Posting).
- "The API is a given." It is a contract, and you are its consumer. Consumer requirements are a normal input to API design, not a special favour (Consumer-First Design).
- "The decision is REST versus GraphQL." The decisions are pagination style, granularity, error model, identity stability and capability exposure. Any of those can be got right or wrong in either (Which API Style Should I Use?).
- "Fewer bytes is the goal." On the connections most users have, sequential round trips usually cost more than payload size — and the two are traded against each other constantly.
- "We will fix it in the client." Client workarounds for contract problems are permanent, invisible to everyone outside the frontend team, and often not fixable at all — a join across two paginated collections cannot be made correct on the client.
- "One endpoint per screen is best practice." It is one option, with a real coupling cost paid every time the screen changes (API Granularity and the Chatty API).
- "Partial failure is an edge case." On mobile networks it is a routine outcome, and a contract that cannot express it guarantees the UI will not handle it (Partial Failure: When 3 of 5 Succeed).
Measuring it, and what changes in the field
- Request count per screen, from the network panel and from field data. It is the number that most reliably predicts perceived load time on a mobile connection (Reading the Browser Waterfall).
- Bytes received versus fields rendered. A large ratio is a direct measure of over-fetching, and it is usually much larger than anyone expects (Payload Size: 20KB, 200KB, 5MB).
- Main-thread time spent in JSON parsing and in post-fetch transformation, which is where over-fetching actually hurts on a slow device (Measure Before Optimising).
- Error-model coverage: the share of failures your client can map to a specific, actionable message. If it is low, that is a contract gap, not a copywriting gap.
- Duplicate and skipped rows in paginated lists — a correctness metric, measurable by checking identifier uniqueness across accumulated pages.
- Round trips on a high-latency network profile, measured deliberately rather than on the office connection (Reading a Network Waterfall).
- On a high-latency connection, round trips dominate and granularity is the only variable that matters. Halving the bytes changes little; halving the number of sequential requests changes everything.
- On a slow device, payload size dominates instead, because parsing and transforming are main-thread work (The Real Cost of JavaScript).
- On a large collection, pagination style stops being a preference and becomes a correctness constraint — offset over a busy list is wrong, not merely inelegant.
- Offline or on a flaky connection, partial failure is the normal case, and a contract with no way to express it forces the client to invent one (Offline UX).
- In an old client, the contract must still hold. Every "we will just change the shape" conversation has to account for the builds still running (Long-Lived Clients and Version Skew).
- A screen-shaped endpoint is fast and couples the API to a UI that will be redesigned. A generic resource API is stable and pushes composition, and its cost, into every client.
- Batching removes round trips and introduces partial-success semantics you now have to model in the UI.
- A query language moves the shape decision to the client and moves the cost to the server, where an expensive query is now something the frontend can write by accident (What GraphQL Costs).
- Capability fields make every response a little larger and remove an entire class of drift between what the UI shows and what the server permits.
- Adapting at a boundary is an extra layer and a small amount of mapping code, and it is what makes a contract change a one-file change.
- Pushing back on a contract costs time and political capital now, against a client-side workaround that costs a little forever and is invisible in every planning conversation.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThat contract shape determines client complexity holds for REST, RPC, GraphQL and anything else, because it follows from round trips, payload size, identity and error localisation rather than from a protocol.
- NETWORK-SPECIFICWhich contract property hurts most depends on the connection: on a high-latency mobile link the request count dominates and payload size barely registers, while on a fast link with a slow device the ordering reverses and parse cost is the constraint. Optimising for the wrong one is the usual outcome of measuring only on an office network.
- SIMPLIFIEDThe examples treat one client consuming one API. Real contracts usually serve several clients — a web app, a mobile app, a partner integration — whose requirements conflict, which is precisely the pressure that produces a BFF (Backend for Frontend).
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — the boundary module that adapts a wire shape to a view model is an anti-corruption layer, and the reasons for having one are the same here as anywhere else.
- — Testing & Reliability Engineering — consumer-driven contract tests, which are how a frontend team turns "we depend on this shape" into something that fails in the provider's pipeline rather than in production.