Payload Size: 20KB, 200KB, 5MB
Payload cost is paid four times — transfer, serialization, memory, client parse — and it scales with every caller. A 20KB response is a non-event, 200KB is a tax on every mobile render, and 5MB is an architecture mistake wearing a JSON costume.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Paid four times, at every size
A response body is not one cost but four. Transfer: bytes over the consumer's actual link — 200KB is ~320ms on a 5 Mbps mobile connection, before any parsing. Serialization: the server walks the object graph and encodes it, per request, per caller; at 1,000 RPS, 200KB responses mean encoding 200MB/s of JSON. Memory: both sides hold the payload, and a parsed JSON object graph typically occupies 4–10× its wire size — a 5MB body becomes a 30MB allocation spike on a phone. Parse: JSON.parse on a mid-range device runs at roughly 25–50 MB/s and blocks the main thread; 5MB is a 100–200ms UI freeze.
The tiers behave differently in kind, not just degree. At 20KB every cost rounds to zero and no design attention is owed. At 200KB each cost is individually survivable but shows up in percentiles: transfer dominates mobile renders, serialization shows in server CPU profiles, and the response no longer fits nicely in caches. At 5MB the request stops behaving like an API call: proxies buffer it, mobile OSes kill the tab or app under memory pressure, timeouts fire mid-body, and a retry re-pays the whole cost. Above roughly 1MB you are no longer designing a response — you are designing a download that should have a different contract (see File Upload APIs: Authorize, Upload Directly, Confirm for the mirror-image on the write side).
| Cost | 20KB | 200KB | 5MB |
|---|---|---|---|
| Transfer (5 Mbps) | ~32ms — invisible | ~320ms — dominates the render | ~8s — timeouts fire mid-body |
| Server serialization at 1k RPS | 20 MB/s — negligible | 200 MB/s — visible in CPU profiles | 5 GB/s — not servable; the endpoint caps its own throughput |
| Client memory (parsed, ~6×) | ~120KB | ~1.2MB | ~30MB — OOM-kill territory on low-end devices |
| Main-thread parse | <1ms | ~4–8ms | ~100–200ms of frozen UI |
Where the bytes come from
Payload bloat is rarely one big field; it is a shape decision compounding. The classic sources: entity dumps — serializing the model with all 60 columns when the screen reads five (see Response Contracts Are Not Database Rows); embedded collections — a project response carrying every task, each task carrying its full author object, multiplying entities into the response; unbounded arrays — a list that grows with customer age until the biggest customer's response is 400× the median (see Unbounded Collections: The Anti-Pattern With a Fuse); and binary in JSON — base64-encoding images or PDFs into the body, paying a 33% size premium to put a download where a link belongs.
The insidious property is that every source grows *after* launch. The entity gains columns, the customer gains tasks, product asks for "just one more embedded object". Payload size is the metric most likely to be fine at review time and pathological two years later — which is why the defense has to be structural (caps and shapes), not a one-time audit.
1GET /projects/422→ 200 OK · 1.8MB3{4 "project": { …60 columns… },5 "tasks": [ …3,100 tasks, each with full "author" object… ],6 "attachments": [ { "name": "spec.pdf",7 "data": "<base64, 900KB>" } ]8}9# median customer: 40KB. p99 customer: 1.8MB.10# same endpoint, same contract, 45× the cost1GET /projects/422→ 200 OK · 6KB3{4 "id": "prj_42", "name": "…", "task_count": 3100,5 "tasks_url": "/projects/42/tasks?limit=50",6 "attachments": [ { "name": "spec.pdf",7 "href": "https://files.example/…",8 "bytes": 921600 } ]9}10# tasks paginate; bytes live behind links;11# the p99 customer costs what the median customer costsThe bounded shape is not smaller because the data shrank — it is smaller because the contract stopped promising to inline everything. Collections paginate, blobs are links, and response size becomes independent of customer size.
Caps, fields and links as contract clauses
The structural defenses are all contract features. State a maximum response size the same way you state a rate limit, and design every collection so it can be met: pagination with a maximum limit (see Pagination: Choosing How Lists End), embedded collections capped with a …_url continuation, and depth limits on any ?include= mechanism. Offer field selection where consumers genuinely diverge — ?fields= or task-shaped endpoints — so the mobile client is not billed for the admin console's columns (see Over-Fetching and Under-Fetching).
Move bytes that are not data out of the body: files, exports, images and reports are links to a download, ideally served from storage or a CDN rather than proxied through the API, with resumable range requests for anything a mobile network might drop. And treat request bodies with the same suspicion — an unbounded upload or batch is the same failure pointed at your own memory (see Large Requests and Documented Limits).
Compression is deliberately absent from this list. It divides the transfer cost by 5–10× and touches none of the other three — the server still serializes, both sides still hold the parsed graph, the client still parses every byte. Compression is a good second lever (Compression: Cheaper Bytes, Not Fewer) and a terrible substitute for a bounded shape.
- Cap every collection — a maximum page size is a promise about the biggest response you will ever send.
- Fields follow the reader — field selection or per-consumer shapes keep each caller's bytes proportional to their screen.
- Blobs are links — base64 in JSON pays +33% size and buys nothing; a URL with
bytesand a checksum is the honest contract. - Bound the includes — every
?include=combination you allow is a payload multiplier you must have measured. - Watch p99 payload, not p50 — bloat lives in the biggest customer, and it arrives after launch.
Key points
- Payload is paid four times — transfer, serialization, memory, parse — and each cost scales with size and with caller count.
- The tiers differ in kind: 20KB is free, 200KB is a visible tax on mobile renders and server CPU, 5MB stops behaving like an API response at all.
- Parsed JSON occupies 4–10× its wire size in memory; the phone pays for your response twice.
- Bloat compounds after launch — entity dumps, embedded collections, unbounded arrays and base64 blobs all grow with the data.
- The defenses are structural contract clauses: capped pages, field selection, links instead of blobs, bounded includes.
- Compression divides one of the four costs; it never substitutes for a bounded shape.
Payload Size Visualizer
Change the contract and observe which guarantee moves.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: serializes the entity graph because it is what the ORM returns; staging responses are 30KB and nothing looks wrong.
- 2Data → responses: two years of customer growth put 3,000 tasks in the biggest project; the same endpoint now returns 1.8MB.
- 3Mobile client → users: renders freeze for 200ms on parse and OOM-crash on low-end devices; the crash reports do not mention the API.
- 4Server → fleet: serialization CPU and egress climb with the p99 customer; timeouts on the biggest tenants trigger retries that re-send the megabytes.
- 5Team → fix: pagination is retrofitted onto the embedded array — a breaking change, shipped as an emergency, to the customers most affected.
- Client experience degrades with customer size: your biggest, most valuable tenants get the slowest screens and the most crashes.
- Server throughput is capped by serialization and egress on the fat endpoint; capacity planning is hostage to one response shape.
- Retries multiply the damage: a timeout at megabyte three re-pays the full transfer, and a fleet of retrying clients turns one slow endpoint into a bandwidth incident (see Retries and Timeouts as Contract Guidance).
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Bound every response structurally: maximum page sizes on all collections, capped embeds with continuation URLs, depth-limited includes.
- • Serve binary and bulk data as links to storage/CDN downloads with range support — the API carries coordinates, not payloads.
- • Provide field selection or consumer-shaped endpoints where reader needs diverge, so bytes track the screen, not the schema.
- • Set and monitor a payload budget per endpoint (p50 and p99), and treat p99 growth as a design regression, not an ops fact.
- • Payload percentiles per endpoint, with p99/p50 ratio: a widening ratio means response size has become a function of customer size.
- • Client-side parse time and memory watermarks from real devices (RUM), which catch the cost server metrics are blind to.
- • Timeout-then-retry sequences on large responses in the gateway logs — the signature of a payload that outgrew its transfer window.
- • Adding field selection, pagination parameters and link-shaped attachments is additive; keep old defaults intact while consumers migrate.
- • Shrinking a default response is breaking: introduce the lean shape (new fields param, new endpoint or new version), measure adoption per consumer, then deprecate the fat default (see [[removing-fields]]).
- • When a response must genuinely grow, prefer a new optional embed over inflating the default — the default shape is the promise every existing consumer sized their buffers against.
- • Bounded shapes push work to consumers: pagination loops and follow-up fetches where one fat response used to arrive whole.
- • Field selection and capped embeds add validation, documentation and cache-key complexity — every shape variant is a variant to test.
- • Links-not-blobs introduces a second fetch and an auth story for the download URL; for tiny images the inline base64 would honestly have been simpler.