Fundamentalsgranularitychatty apiround tripsaggregation

API Granularity and the Chatty API

Too fine and every task takes ten round trips; too coarse and every call hauls a kitchen sink. Granularity is a per-consumer decision, and the network — not aesthetics — is what punishes getting it wrong.

Follow the failure

Frame the contract

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

Design question
Is the boundary of each operation matched to the tasks and network position of the consumers that call it?
Consumers
Any client whose task spans several entities — most visibly UI clients rendering screens over real-world networks, and batch consumers processing thousands of items.
The promise
A well-granulated API lets a consumer finish a task in a number of round trips that its network position can afford, without paying for data it does not need.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

The chatty API, measured

Chattiness is not an aesthetic complaint; it is arithmetic. A screen that needs GET /user, /projects, /tasks, /notifications and /billing pays five round trips. Sequential on a 200ms-RTT mobile connection, that is a second of pure protocol before any rendering. Parallelizing helps until dependencies ("need the project ids before fetching tasks") serialize it again.

The provider pays too: five requests carry five auth checks, five log lines, five rate-limit decisions. The cost of a request is never just its payload — per-request overhead is why Batch APIs and Partial Failure and aggregated reads exist at all.

The mirror-image failure is the kitchen-sink response: one endpoint returns 80 fields and four embedded collections because *some* consumer once needed each of them. Every other consumer now pays parse time, transfer size and coupling to fields it never reads. Granularity errors in both directions are consumer-mismatch errors.

One dashboard render, as the network sees it (200ms RTT, sequential where dependent)
GET /user            200ms   →  need user.org_id first
GET /orgs/9/projects 200ms   →  need project ids
GET /tasks?project=…  200ms
GET /notifications    200ms   (parallel with tasks)
GET /billing/summary  200ms   (parallel)
                     ─────
         ≥ 600ms serialized + 3 parallel calls of overhead
         5 auth checks · 5 rate-limit decisions · 5 log lines

Matching the boundary to the consumer

The question "is this API too chatty?" has no answer without naming the consumer. Service-to-service calls inside one datacenter pay ~0.5ms per hop; five calls are irrelevant. A browser on hotel Wi-Fi pays three orders of magnitude more per hop; the same five calls dominate the experience. This is why the granularity decision belongs to the *surface* (which consumers it serves), not to the domain.

The standard resolutions, in order of increasing machinery: add an aggregated read for a known task ("GET /dashboard"); allow bounded composition (embedding related resources on request — ?include=projects); introduce a Backend for Frontend that owns experience-shaped aggregation; or adopt client-driven selection (GraphQL: Client-Shaped Queries Over One Schema) when the set of screen shapes is genuinely unknowable. Each step buys flexibility and costs operational surface — take the cheapest one that solves the measured problem.

Resolutions for chattiness, cheapest first
MechanismSolvesCostsReach for it when
Task-shaped aggregate endpointOne known screen/workflowOne more endpoint to ownThe task is stable and shared by few consumers
?include= compositionCommon parent+child readsResponse variability, N+1 risk in the provider (see What GraphQL Costs)A handful of well-known combinations
Backend-for-frontendPer-client experience shapeA service per client type to own and deployClient teams iterate on screens weekly
Client-driven selection (GraphQL)Unknowable combinations of fieldsResolver, authorization and cost-control complexityMany consumers, many shapes, one graph

Coarse writes are a different question

Aggregating reads is mostly free of semantic risk — the worst case is staleness. Aggregating *writes* is not: a POST /setup-account that creates a user, a project and a subscription in one call must now answer what happens when the third step fails. Partial failure, retry semantics and idempotency scope all get harder as write granularity grows (see Partial Failure: When 3 of 5 Succeed and There Is No Transaction Across APIs).

A useful asymmetry follows: prefer coarse reads shaped like tasks, and writes shaped like single domain operations with explicit semantics. When a workflow genuinely spans several writes, model the workflow itself as a resource with observable state instead of hiding it inside one megacall — that is the The Async Job Pattern applied to composition.

Key points

  • Chattiness is arithmetic: round trips × RTT, plus per-request overhead on both sides. Measure it per consumer task.
  • Granularity has two failure directions — chatty and kitchen-sink — and both are consumer-mismatch errors.
  • The same call count is fine intra-datacenter and disastrous on mobile; granularity belongs to the surface, not the domain.
  • Escalate resolution machinery in order: aggregate endpoint → bounded includes → BFF → client-driven selection.
  • Aggregate reads freely; aggregate writes reluctantly, because partial failure and idempotency get harder with every combined effect.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: exposes one endpoint per entity, uniform for all consumers, because it mirrors the service's internals.
  2. 2
    UI team → API: assembles each screen from 5–8 entity calls; dependencies serialize them.
  3. 3
    Users → app: screens crawl on real networks; the UI team demands "a faster API".
  4. 4
    Team → API: bolts a kitchen-sink ?expand=everything onto the busiest endpoint; every consumer now receives everything.
  5. 5
    Provider → infra: payloads and load balloon; the granularity error has now been made in both directions at once.
What breaks
  • User-facing latency scales with round trips, not with backend speed — no backend optimization can win it back.
  • Provider overhead multiplies: auth, logging and rate decisions per request, N requests per task.
  • Kitchen-sink responses couple every consumer to every field, making later slimming a breaking change.

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
  • • Budget round trips per consumer task explicitly (e.g. "dashboard: ≤2 requests") and design reads to meet the budget.
  • • Keep entity endpoints lean and add task-shaped aggregates where budgets demand them; do not fatten the entity endpoints.
  • • Bound any `?include=` mechanism to an allowlist of combinations you have tested for provider-side N+1 cost.
  • • Keep writes single-operation with explicit semantics; model multi-write workflows as observable resources.
Observe in production
  • • Trace real consumer sessions: requests-per-screen and serialized depth are directly measurable (see [[request-ids]]).
  • • Alert on payload percentiles per endpoint; a kitchen-sink response shows up as p50 payload growth after each release.
  • • Provider-side, watch fan-out per inbound request — an aggregate endpoint hiding an internal N+1 has the same chattiness one hop deeper.
Evolve without breaking
  • • Aggregates added for a task can be deprecated with the task; entity endpoints underneath stay stable.
  • • Moving from includes → BFF → GraphQL is an additive path if entity endpoints stay lean; kitchen-sink endpoints block it because their shape is load-bearing.
What it costs
  • • Task-shaped aggregates are more surface to own, and each couples to several sources — its availability is the product of theirs.
  • • Round-trip budgets constrain frontend flexibility: a new screen may need API work where a chatty API would have let it ship (slowly) today.

Misconceptions

Claim
“HTTP/2 multiplexing makes chattiness free.”
Reality
Multiplexing removes head-of-line blocking per connection; it does not remove RTTs for dependent calls, per-request auth and rate overhead, or the client code that has to orchestrate eight fetches. See Slow Clients and Backpressure for the transport view.
Claim
“The fix for chattiness is always GraphQL.”
Reality
GraphQL is the last resort on the escalation ladder, not the first. A single aggregate endpoint fixes a stable screen for a fraction of the operational cost.