Requestslimitsbody size413query complexityparsermemory

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.

Follow the failure

Frame the contract

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

Design question
How big can a request be before this API refuses it — in bytes, items, nesting depth and query cost — and does the client learn the limit from the docs or from a stack trace?
Consumers
A bulk importer sending its whole dataset in one body; a mobile client attaching a photo to a JSON request; a partner whose export grows 10% a month; a search client composing an ever-longer filter; and an attacker who has read none of your docs and is looking for the limit you forgot.
The promise
The contract states each limit — maximum body size, array length, string length, nesting depth, query complexity, file size, batch size — returns a specific, machine-readable rejection when one is exceeded, and enforces it before the request costs anything expensive.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

The limits a request contract should state, where to enforce each, and what an unstated one becomes
LimitEnforce atRejectionIf unstated, enforced by
Body bytesGateway / proxy, before buffering413 + max_bytes in bodyProxy default with an HTML error, or the OOM killer
Array lengthSchema validation, streaming if possible422 ARRAY_TOO_LONG + maxDeserializer memory; a minutes-long transaction
String lengthSchema validation422 TOO_LONG per fieldColumn width errors (500), index bloat
Nesting depthParser configuration422 TOO_DEEPStack overflow in a recursive parser
Query complexityQuery planner / cost estimator before execution422 QUERY_TOO_COMPLEXDatabase CPU; a 504 for everyone else
Decompressed sizeDecompressor with a ratio cap413 on expansionMemory exhaustion from a tiny request
File sizeUpload authorization, then storage413 or 422 FILE_TOO_LARGEThe 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.

A limits section as it should appear in the contract
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.

  1. 1
    Team → API: ships POST /imports accepting a JSON array with no cap; the proxy default of 1MB is the only limit and nobody knows it.
  2. 2
    Partner → import: sends 900KB; works. Six months later sends 1.1MB; receives a 413 with an nginx HTML page and opens a ticket.
  3. 3
    Team → fix: raises the proxy limit to 100MB; a 60MB import now parses into 500MB of objects and holds a transaction for three minutes.
  4. 4
    Other consumers → API: every request on the same workers slows; the database shows lock waits from the import transaction.
  5. 5
    Attacker → API: sends a 2KB body nested 100,000 deep; the recursive parser overflows the stack and the worker restarts, repeatedly.
What breaks
  • 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.

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

Misconceptions

Claim
“We do not have request limits; we accept whatever clients send.”
Reality
The proxy, the parser and the kernel disagree. The API has limits; the contract just does not know what they are, so neither do consumers until they hit one.
Claim
“A body-size cap at the gateway is enough.”
Reality
Bytes on the wire are a poor proxy for cost: a small deeply-nested body, a compressed bomb, or a 500-clause filter each cost far more than their byte count. Each axis needs its own cap.
Claim
“If consumers need bigger requests, raise the limit.”
Reality
Raising the limit raises the memory, parse time and transaction duration every other consumer shares. Past a point the fix is a different shape — async jobs and direct uploads — not a bigger number.

Apply it