Operationspayloadresponse sizeserializationmemorymobile

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.

▶ Run the labFollow the failure

Frame the contract

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

Design question
What does each response size cost across the four places it is paid, and where should the contract cap it?
Consumers
The mobile client parsing on a mid-range phone's main thread, the browser holding the object graph in memory, the server serializing under load, and the proxy in between buffering the whole thing.
The promise
A size-aware contract keeps responses proportional to the task — fields the consumer reads, pages the consumer can process — and moves genuinely large data out of the JSON body into links and streams.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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).

The same response at three sizes (5 Mbps link, mid-range phone, 1,000 RPS service)
Cost20KB200KB5MB
Transfer (5 Mbps)~32ms — invisible~320ms — dominates the render~8s — timeouts fire mid-body
Server serialization at 1k RPS20 MB/s — negligible200 MB/s — visible in CPU profiles5 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.

The response that grows forever
1GET /projects/42
2200 OK · 1.8MB
3{
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 cost
Bounded shape: size is capped by design, not by luck
1GET /projects/42
2200 OK · 6KB
3{
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 costs

The 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 bytes and 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.

Payload Size Visualizer
The same response on three networks. Transfer = size ÷ bandwidth + one RTT; parsing and memory scale with the raw bytes.
Office fiber36ms
Good 4G220ms
Hotel Wi-Fi950ms
Parse cost (main thread, mid-range phone)
~20ms to parse 200 KB of JSON — compression does not help here; the parser sees the raw bytes.
Server memory at 1,000 concurrent responses
~200 MB of response buffers — payload size is a capacity decision, not just a latency one.

Follow the failure

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

  1. 1
    Team → API: serializes the entity graph because it is what the ORM returns; staging responses are 30KB and nothing looks wrong.
  2. 2
    Data → responses: two years of customer growth put 3,000 tasks in the biggest project; the same endpoint now returns 1.8MB.
  3. 3
    Mobile client → users: renders freeze for 200ms on parse and OOM-crash on low-end devices; the crash reports do not mention the API.
  4. 4
    Server → fleet: serialization CPU and egress climb with the p99 customer; timeouts on the biggest tenants trigger retries that re-send the megabytes.
  5. 5
    Team → fix: pagination is retrofitted onto the embedded array — a breaking change, shipped as an emergency, to the customers most affected.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“Bandwidth is cheap now — payload size stopped mattering.”
Reality
Transfer is one cost of four. Serialization CPU, the 4–10× parsed-memory multiplier and main-thread parse time all scale with size regardless of link speed, and mobile p75 bandwidth is far worse than your office Wi-Fi.
Claim
“We compress, so payload size is handled.”
Reality
Compression divides transfer only. The server still serializes every byte, both sides still hold the parsed graph, and the client still parses it. A 5MB response gzipped to 800KB is still a 30MB allocation and a 100ms+ parse on a phone.
Claim
“Big responses are more efficient — fewer round trips.”
Reality
True until the response exceeds what the consumer can transfer inside its timeout and hold in memory. Past that point, one 5MB response loses to 25 pages of 200KB that stream, cache, resume and fail independently.

Apply it