Over-Fetching and Under-Fetching
GET /users/42 returns 80 fields when the screen needs 3; the dashboard makes 8 calls to render once. Both are granularity mismatches between one generic contract and many specific consumers — and the fixes (sparse fieldsets, expansion, GraphQL, a BFF) each move the cost somewhere else.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Two symptoms of one mismatch
Over-fetching: the resource representation is the union of what every consumer ever needed, so the list screen that shows name and avatar downloads the full profile, preferences, billing summary and last twenty audit entries for each row. The cost is bytes, serialization CPU and — often forgotten — the database joins the server ran to populate fields nobody read (see Payload Size: 20KB, 200KB, 5MB for the size arithmetic).
Under-fetching: the resources are fine-grained and normalized, so rendering one dashboard needs the user, then the projects, then each project's owner, then the notifications, then the billing status — eight sequential calls, each adding a full round trip, each with its own auth and error handling. On a mobile network at 300ms RTT that is 2.4 seconds of pure waiting before the client can paint, and API Granularity and the Chatty API shows how it compounds as screens grow.
They are one problem: the contract has one shape per resource and many consumers with different tasks. Making the shape bigger fixes under-fetching for one screen and worsens over-fetching for every other. Making it smaller does the reverse. The way out is not a better single shape; it is a mechanism that lets the shape vary per consumer — and every such mechanism has an operational price.
| Mechanism | Fixes | Client experience | Server cost | Caching | Use when |
|---|---|---|---|---|---|
Purpose-built resources (/users/{id}/summary) | Both, for known screens | One call, fixed shape | One handler per shape | Excellent — fixed URLs | A few well-known consumers with stable screens |
Sparse fieldsets (?fields=id,name,avatar) | Over-fetching | Client picks fields; must know names | Field allowlist; conditional joins | Good — URL includes fields | Read-heavy lists, many field combinations |
Expansion (?expand=owner,members) | Under-fetching | Fewer round trips; bigger responses | Bounded joins per expansion | Good — URL includes expansions | Common relationships, bounded depth |
| GraphQL: Client-Shaped Queries Over One Schema | Both, for arbitrary shapes | Client declares the exact shape | Resolver cost control, N+1 (What GraphQL Costs) | Hard — POST, per-query | Many clients, many screens, a team to run it |
| Backend for Frontend | Both, per client type | One aggregate call per screen | A service per client to own | Per-screen | Distinct clients with distinct needs and owners |
Fieldsets and expansion: the cheap 80%
Sparse fieldsets are the smallest intervention: GET /users?fields=id,name,avatar_url returns only those fields. The server needs an allowlist (never a pass-through to SELECT), a default set when the parameter is absent, and the discipline to skip the joins that populate excluded fields — otherwise the bytes shrink but the database cost does not. Fieldsets compose naturally with Filtering: An Allowlist With an Index Bill and Pagination: Choosing How Lists End, and because the selection is in the URL, HTTP caches still work.
Expansion is the inverse: GET /projects/{id}?expand=owner,members inlines related resources that would otherwise take extra calls. The contract must bound it — a fixed list of expandable relationships, a maximum depth (members.user yes, members.user.projects.members no), and a maximum count per expanded collection — because unbounded expansion is a query-complexity problem that ends in Large Requests and Documented Limits territory. Documented expansions also declare which relationships the server is willing to join efficiently, which is a promise about indexes (Why Is This Query Slow? Indexes).
Together, fieldsets and expansion solve most granularity mismatches without a new API style, and their limitation is honest: the client can only select and expand along the shapes the server defined. When consumers need shapes the server did not anticipate — a screen joining three resources in a way no expansion covers — the next steps are a purpose-built aggregate endpoint, a BFF, or GraphQL.
GET /projects?fields=id,name,updated_at&expand=owner&limit=20 Authorization: Bearer …
200 OK
Cache-Control: private, max-age=30
{
"data": [
{ "id": "proj_18a2", "name": "Atlas", "updated_at": "2026-08-25T10:14:03Z",
"owner": { "id": "usr_7f9c", "display_name": "A. Bee", "avatar_url": "https://…/7f9c.png" } }
],
"next_cursor": "eyJ1cGRhdGVkX2F0Ijo…"
}
# Without fieldsets: 41 fields per project. Without expand: +20 GET /users/{id} calls.When one contract cannot serve everyone
Beyond fieldsets and expansion, the options are architectural. A purpose-built aggregate endpoint (GET /dashboard) is honest about being for one screen; it is fast, cacheable and easy to reason about, and it will multiply — one per screen — until someone asks who owns them. A [[backend-for-frontend]] moves that multiplication into a service per client type with a clear owner, at the cost of another deployable. [[graphql]] lets clients declare arbitrary shapes and relocates the granularity problem into resolvers, batching and cost control — a good trade for many clients and many screens, a bad one for two consumers and a small team.
The evaluation question for any of them is the one Consumer-First Design started with: *how many round trips and how many bytes does the most important consumer task cost?* Measure it — a mobile client's first screen at 2.4 seconds is a number product understands. Then pick the mechanism whose operational cost your team can carry, not the one whose demo looks best.
The mistake in both directions is dogma. "Every resource must be fully normalized" produces chatty clients (API Anti-Patterns Field Guide); "just return everything, bandwidth is cheap" produces 5MB list responses and servers doing joins for fields nobody reads. The contract should say, per consumer class, which mechanism to use — and the Documentation Is Part of the Contract should show the one-call version of every important screen.
- Measure first: round trips and bytes for the top consumer tasks; a number beats a preference.
- Fieldsets for over-fetching, bounded expansion for under-fetching — the cheap 80%.
- Aggregate endpoints for a few known screens; BFF when clients diverge and need owners.
- GraphQL when shapes are genuinely unpredictable and a team can run resolvers, batching and cost limits.
- Document the one-call path for every important screen; consumers cannot use what they cannot find.
Key points
- Over-fetching and under-fetching are one mismatch — one shape per resource, many consumers — and enlarging or shrinking the shape just moves the pain.
- Sparse fieldsets (allowlisted, join-aware) and bounded expansion solve most cases without changing API style and keep HTTP caching intact.
- Aggregate endpoints, BFFs and GraphQL each fix granularity by adding something to own: endpoints, services, or resolvers with cost control.
- Measure round trips and bytes for the most important consumer task before choosing; a 2.4-second first paint is an argument product understands.
- Dogma in either direction — full normalization or return-everything — produces the anti-pattern it was trying to avoid.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → resource: models
/usersas one full representation because "that is what a user is". - 2Mobile → list screen: fetches 20 users × 80 fields to show 20 names and avatars; 1.2MB per page over cellular.
- 3Web → dashboard: normalized resources force 8 sequential calls; first paint at 2.4s on 300ms RTT.
- 4Team → fix: adds fields to
/usersfor the dashboard, making the mobile list heavier; adds/dashboard-v2for mobile, nobody owns it. - 5Product → team: "why is the app slow?" arrives with two contradictory fixes already shipped.
- Client performance degrades in ways the server never sees: the API is fast per call, the screen is slow per task.
- Server load grows on joins and serialization for fields no consumer reads; database cost is invisible in API latency dashboards until it is not.
- Ad hoc aggregate endpoints multiply without owners and freeze screens into the contract.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Design list representations as summaries (the fields every list consumer needs) with full detail on the single-resource endpoint.
- • Offer allowlisted sparse fieldsets with join-aware implementation, and bounded expansion (fixed relationships, max depth, max count).
- • Provide purpose-built aggregate endpoints for the few screens that matter most, with named owners.
- • Escalate to a [[backend-for-frontend]] or [[graphql]] only when consumer classes diverge or shapes are unpredictable — and staff the operational cost.
- • Document the one-call path for each important consumer task, with the exact fieldset and expansion parameters.
- • Requests-per-screen and bytes-per-screen from client telemetry (RUM), which server-side p99 never shows (see [[api-metrics]]).
- • Database time attributed to fields excluded by fieldsets — if it does not drop when fields are excluded, the joins are still running.
- • Distribution of `fields=` and `expand=` values in logs reveals which shapes consumers actually want and which aggregate endpoints to build.
- • Adding a fieldset-selectable field or a new expansion is additive; removing an expandable relationship is breaking for every client that used it.
- • Introducing a summary representation for lists where a full one shipped is breaking — do it at a version boundary or as a new endpoint with migration telemetry.
- • A BFF or GraphQL layer can be introduced in front of existing resource endpoints without changing them; the resource API becomes the internal contract.
- • Fieldsets and expansion add parsing, allowlisting and conditional query logic to every list handler.
- • Aggregate endpoints are fast and cacheable but couple the contract to screens; BFFs uncouple it at the cost of a service per client.
- • GraphQL buys maximal flexibility and sells you N+1, cost control, and a harder caching story.