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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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.
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 linesMatching 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.
| Mechanism | Solves | Costs | Reach for it when |
|---|---|---|---|
| Task-shaped aggregate endpoint | One known screen/workflow | One more endpoint to own | The task is stable and shared by few consumers |
?include= composition | Common parent+child reads | Response variability, N+1 risk in the provider (see What GraphQL Costs) | A handful of well-known combinations |
| Backend-for-frontend | Per-client experience shape | A service per client type to own and deploy | Client teams iterate on screens weekly |
| Client-driven selection (GraphQL) | Unknowable combinations of fields | Resolver, authorization and cost-control complexity | Many 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.
- 1Team → API: exposes one endpoint per entity, uniform for all consumers, because it mirrors the service's internals.
- 2UI team → API: assembles each screen from 5–8 entity calls; dependencies serialize them.
- 3Users → app: screens crawl on real networks; the UI team demands "a faster API".
- 4Team → API: bolts a kitchen-sink
?expand=everythingonto the busiest endpoint; every consumer now receives everything. - 5Provider → infra: payloads and load balloon; the granularity error has now been made in both directions at once.
- 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.
- • 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.
- • 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.
- • 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.
- • 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.