Requestsgranularityover-fetchingunder-fetchingsparse fieldsetsexpansionchatty

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
Whose screen is this contract shaped for — and when it is shaped for nobody in particular, who pays: the network, the server, or the client that makes eight round trips?
Consumers
A mobile app rendering a list on a 300ms-RTT connection; a web dashboard composing five resources into one view; a partner that needs two fields from ten thousand records; an internal service that needs the whole object and is annoyed by any trimming.
The promise
The contract lets each consumer get the data one task needs in a bounded number of round trips and a bounded number of bytes — and states which mechanism (fixed shape, fieldsets, expansion, aggregate endpoint) each consumer should use.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

Mechanisms for letting the shape vary, and where each one moves the cost
MechanismFixesClient experienceServer costCachingUse when
Purpose-built resources (/users/{id}/summary)Both, for known screensOne call, fixed shapeOne handler per shapeExcellent — fixed URLsA few well-known consumers with stable screens
Sparse fieldsets (?fields=id,name,avatar)Over-fetchingClient picks fields; must know namesField allowlist; conditional joinsGood — URL includes fieldsRead-heavy lists, many field combinations
Expansion (?expand=owner,members)Under-fetchingFewer round trips; bigger responsesBounded joins per expansionGood — URL includes expansionsCommon relationships, bounded depth
GraphQL: Client-Shaped Queries Over One SchemaBoth, for arbitrary shapesClient declares the exact shapeResolver cost control, N+1 (What GraphQL Costs)Hard — POST, per-queryMany clients, many screens, a team to run it
Backend for FrontendBoth, per client typeOne aggregate call per screenA service per client to ownPer-screenDistinct 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.

One screen, one request: fieldsets trim the rows, expansion inlines the relationship
Request
GET /projects?fields=id,name,updated_at&expand=owner&limit=20
Authorization: Bearer …
Response
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.

  1. 1
    Team → resource: models /users as one full representation because "that is what a user is".
  2. 2
    Mobile → list screen: fetches 20 users × 80 fields to show 20 names and avatars; 1.2MB per page over cellular.
  3. 3
    Web → dashboard: normalized resources force 8 sequential calls; first paint at 2.4s on 300ms RTT.
  4. 4
    Team → fix: adds fields to /users for the dashboard, making the mobile list heavier; adds /dashboard-v2 for mobile, nobody owns it.
  5. 5
    Product → team: "why is the app slow?" arrives with two contradictory fixes already shipped.
What breaks
  • 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 the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“GraphQL solves over- and under-fetching.”
Reality
It solves them for the client and relocates them to the server: resolvers can over-fetch from databases, and every flexible query is a potential N+1. The problem moved to a place where your team must manage it with batching and cost limits (What GraphQL Costs).
Claim
“Bandwidth is cheap; just return everything.”
Reality
Bytes are the visible cost. The invisible ones are the joins the server runs to populate unread fields, the serialization CPU, and the client parsing 1.2MB to show 20 names on a phone.
Claim
“Properly normalized resources are the correct REST design; chatty clients should batch.”
Reality
Normalization is a storage virtue, not a contract virtue. A contract shaped for consumer tasks is the REST design; eight round trips to render one screen is a design defect, not a client problem.

Apply it