Large Requests and Documented Limits
Every API has limits on body size, array length, string length, query complexity and file size. The only question is whether the contract states them — with a status code and the number — or whether a load balancer, a JSON parser or the OOM killer states them for you.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
Every limit exists; undocumented ones are just enforced somewhere worse
A request body with no declared limit is limited anyway: by the reverse proxy's default (client_max_body_size 1m in a stock nginx), by the load balancer, by the framework's parser, by the process's memory. Each of those produces a different failure — a 413 from the proxy with an HTML body the client cannot parse, a 502 when the app crashes, a 504 when a 40MB JSON body takes longer to parse than the upstream timeout — and none of them tells the client what the limit was (HTTP Debugging: 502, 503 and 504 Are Different Failures covers why those three look alike from outside).
The limits that matter are not only bytes. A 2MB body with a single 2-million-element array is a memory problem for whatever deserializes it; a 2MB body nested ten thousand levels deep is a stack overflow for a recursive parser; a 2MB search request with 500 OR clauses is a query planner problem (Filtering: An Allowlist With an Index Bill and Search Is a Different Contract Than Filtering bound complexity, not just size). A 10KB request that expands to 10GB — a "billion laughs" style payload in XML, or a compressed body with a 1000:1 ratio — is a security problem the size check on the wire never sees.
The contract's job is to make each limit a stated number with a stated rejection: 413 Content Too Large for bytes on the wire, 422 with ARRAY_TOO_LONG (and the maximum) for items, 422 QUERY_TOO_COMPLEX for filter cost, 414 URI Too Long for query strings. The limit should be enforced as early as possible — at the gateway for bytes, in the schema validator for lengths and depth, before the database sees a query for complexity — so that an over-limit request is cheap to reject rather than expensive to fail.
| Limit | Enforce at | Rejection | If unstated, enforced by |
|---|---|---|---|
| Body bytes | Gateway / proxy, before buffering | 413 + max_bytes in body | Proxy default with an HTML error, or the OOM killer |
| Array length | Schema validation, streaming if possible | 422 ARRAY_TOO_LONG + max | Deserializer memory; a minutes-long transaction |
| String length | Schema validation | 422 TOO_LONG per field | Column width errors (500), index bloat |
| Nesting depth | Parser configuration | 422 TOO_DEEP | Stack overflow in a recursive parser |
| Query complexity | Query planner / cost estimator before execution | 422 QUERY_TOO_COMPLEX | Database CPU; a 504 for everyone else |
| Decompressed size | Decompressor with a ratio cap | 413 on expansion | Memory exhaustion from a tiny request |
| File size | Upload authorization, then storage | 413 or 422 FILE_TOO_LARGE | The API proxying gigabytes it was never meant to hold — see File Upload APIs: Authorize, Upload Directly, Confirm |
The cost curve behind the numbers
Request size is not linear in cost. A 20KB JSON body parses in microseconds and lives in one buffer. A 200KB body is still fine per request but, at a thousand concurrent requests, is 200MB of buffers in the process (The Buffer Chain and Memory Pressure, Swap and the OOM Killer explain where that memory actually lives). A 5MB body is buffered by the proxy, then by the framework, then deserialized into an object graph three to ten times its wire size, then validated, then — if it is a batch — held for the duration of a transaction. The Payload Size: 20KB, 200KB, 5MB visualizer shows the same curve on the response side; on the request side the server pays it under load it does not control.
That is why the limit is a capacity promise, not an aesthetic one. max 1,000 items per batch means "we have sized memory, transaction duration and timeouts for 1,000". Raising it later is a capacity change; lowering it is a breaking change for every client that sends more. Choosing the number is therefore an engineering decision to make with measurements — parse time, memory per item, transaction time per item — and to revisit when the measurements change.
Security folds into the same limits. Body size caps stop trivial memory exhaustion; depth caps stop parser recursion attacks; decompression ratio caps stop zip bombs; array caps stop one request from consuming a worker for minutes. These are the same numbers the capacity analysis produced, which is why the security domain treats API Security as a Boundary limits as a boundary control rather than a separate feature — and why a limit only enforced deep inside the application is a limit an attacker can spend your resources reaching.
Limits (all operations unless noted)
Request body 1 MB → 413 Content Too Large { "code": "BODY_TOO_LARGE", "max_bytes": 1048576 }
Array fields 1,000 items → 422 { "code": "ARRAY_TOO_LONG", "field": "items", "max": 1000 }
String fields see schema (name ≤ 200, description ≤ 5,000)
Nesting depth 32 → 422 { "code": "TOO_DEEP" }
Query string 8 KB → 414 URI Too Long
Filter clauses 20 per request → 422 { "code": "QUERY_TOO_COMPLEX", "max_clauses": 20 }
Compressed request accepted; decompressed size subject to the 1 MB body limit
File uploads 5 GB via signed upload (not through this API) — see Uploads
Batch endpoints 1,000 items; larger imports use POST /imports (async)What the client does with a limit
A documented limit lets the client design around it: chunk the import into 1,000-item batches, upload the file through the signed-URL flow instead of the JSON body, simplify the filter or move to a saved search. A rejection that carries the limit (max_bytes, max per field) lets an SDK do that automatically — split and retry — which is only safe if the split pieces are independently retryable (Batch APIs and Partial Failure covers per-item dedup). A rejection with no number forces guessing, and clients that guess tend to guess by binary search in production.
Limits are also where the request and response sides meet. An API that accepts a 1MB request and returns a 50MB response has not solved anything; Unbounded Collections: The Anti-Pattern With a Fuse and Pagination: Choosing How Lists End bound the response side, and the same "state the number, return it in the rejection" discipline applies. The consumer-facing summary is a single limits page in the Documentation Is Part of the Contract — the one page a bulk integrator reads first.
When the honest limit is smaller than what consumers need, the answer is not a bigger limit; it is a different shape. Very large imports become an The Async Job Pattern job with a file reference; very large files become a direct upload (File Upload APIs: Authorize, Upload Directly, Confirm); very complex queries become a saved query resource executed asynchronously. The synchronous request stays small enough to be fast for everyone.
- State every limit as a number in the docs and in the rejection body.
- Enforce early: bytes at the gateway, lengths and depth in the validator, complexity before execution.
- Size limits from measurements: memory per item, parse time, transaction time — and revisit them.
- Cap decompression ratio and nesting depth; the wire size is not the cost.
- Change the shape when the limit is too small: async jobs, direct uploads, saved queries.
Key points
- Every request limit exists whether or not the contract states it; unstated limits are enforced by proxies, parsers and the OOM killer, with errors the client cannot act on.
- Bytes are only one axis — array length, string length, nesting depth, query complexity and decompressed size each need their own number and rejection.
- Enforce each limit at the cheapest possible point: an over-limit request should be rejected before it costs memory, CPU or a transaction.
- Limits are capacity promises derived from measurements; raising is a capacity change, lowering is a breaking change.
- When the honest limit is too small for a consumer, change the shape — async job, direct upload, saved query — not the number.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → API: ships
POST /importsaccepting a JSON array with no cap; the proxy default of 1MB is the only limit and nobody knows it. - 2Partner → import: sends 900KB; works. Six months later sends 1.1MB; receives a
413with an nginx HTML page and opens a ticket. - 3Team → fix: raises the proxy limit to 100MB; a 60MB import now parses into 500MB of objects and holds a transaction for three minutes.
- 4Other consumers → API: every request on the same workers slows; the database shows lock waits from the import transaction.
- 5Attacker → API: sends a 2KB body nested 100,000 deep; the recursive parser overflows the stack and the worker restarts, repeatedly.
- Opaque rejections from infrastructure layers that clients cannot parse or act on, and cannot distinguish from outages.
- Memory and transaction time consumed by a few large requests degrade latency for every consumer on the same workers.
- Trivial resource-exhaustion attacks through parser depth, decompression ratio or array size, all invisible to a byte-count check.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Declare body, array, string, depth, query-complexity and decompression limits with numbers, and return the number in every rejection.
- • Enforce at the gateway for bytes, in shared schema validation for lengths and depth, and in a cost estimator before execution for queries.
- • Derive limits from measurements of memory and time per item; record the reasoning in the decision log.
- • Route oversized needs to other shapes: async import jobs, direct-to-storage uploads, saved queries.
- • Keep one limits page in the documentation and make the SDK surface limit errors with the numbers attached.
- • Rate of `413`/`414`/`422` limit rejections by consumer and by limit type — rising counts mean the shape is too small, not that clients are wrong.
- • Request-size distribution (p50/p99 bytes and items) per endpoint against the cap shows headroom before it runs out.
- • Worker memory and transaction duration correlated with request size reveal limits set above what capacity supports.
- • Raising a limit is additive and a capacity decision; lowering one needs telemetry on who exceeds the new value and a deprecation window.
- • Adding a new limit type (e.g. query complexity) to an API that never had one is breaking for the tail of consumers; announce, measure, then enforce.
- • Introducing an async alternative before tightening a synchronous limit gives large consumers somewhere to go.
- • Early enforcement means limits live in several layers (gateway, validator, planner) that must agree; drift between them reproduces the opaque-rejection problem.
- • Conservative limits are safe for the server and annoying for bulk consumers; generous limits are the reverse. The number is a capacity trade, not a correctness one.
- • Streaming validation that rejects an over-long array before buffering it is harder to write than parse-then-check.